chore: remove dead LogsTab and AppConfigPage files
Both replaced by consolidated Deployments tab. ~1300 lines removed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
.widePanel {
|
||||
width: 520px !important;
|
||||
}
|
||||
|
||||
.panelSection {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panelSectionHeader {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
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: 12px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sectionSummary {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tapBadges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.routeLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tapBadgeLink {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.tapBadgeLink:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.panelToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panelMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.editBtn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams, useParams } from 'react-router';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import {
|
||||
DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useCanControl } from '../../auth/auth-store';
|
||||
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();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function logLevelColor(level?: string): BadgeColor {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'ERROR': return 'error'; case 'WARN': return 'warning'; case 'DEBUG': return 'running'; case 'TRACE': return 'auto'; 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';
|
||||
}
|
||||
}
|
||||
function payloadColor(mode?: string): BadgeColor {
|
||||
switch (mode?.toUpperCase()) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table columns (overview) ─────────────────────────────────────────────────
|
||||
|
||||
function buildColumns(): Column<ConfigRow>[] {
|
||||
return [
|
||||
{ key: 'application', header: 'Application', sortable: true, render: (_v, row) => <MonoText size="sm">{row.application}</MonoText> },
|
||||
{ key: 'applicationLogLevel', header: 'App Log', render: (_v, row) => { const val = row.applicationLogLevel ?? 'INFO'; return <Badge label={val} color={logLevelColor(val)} variant="filled" />; } },
|
||||
{ key: 'agentLogLevel', header: 'Agent Log', render: (_v, row) => { const val = row.agentLogLevel ?? '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> },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Detail Panel Content ─────────────────────────────────────────────────────
|
||||
|
||||
function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const canEdit = useCanControl();
|
||||
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]);
|
||||
|
||||
// processorId → routeId mapping from backend
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(appId);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
|
||||
agentLogLevel: config.agentLogLevel ?? '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({
|
||||
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
|
||||
agentLogLevel: config.agentLogLevel ?? '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: ConfigUpdateResponse) => {
|
||||
setEditing(false);
|
||||
if (saved.pushResult.success) {
|
||||
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
|
||||
} else {
|
||||
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
|
||||
toast({ title: 'Config saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
|
||||
}
|
||||
},
|
||||
onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); },
|
||||
});
|
||||
}
|
||||
|
||||
function navigateToTaps(processorId: string) {
|
||||
const routeId = processorToRoute[processorId];
|
||||
onClose();
|
||||
if (routeId) {
|
||||
navigate(`/routes/${appId}/${routeId}?tab=taps`);
|
||||
} else {
|
||||
navigate(`/routes/${appId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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: 'route' as any, header: 'Route', render: (_v, row) => {
|
||||
const routeId = processorToRoute[row.processorId];
|
||||
return routeId ? <span className={styles.routeLabel}>{routeId}</span> : <span className={styles.hint}>—</span>;
|
||||
}},
|
||||
{ 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 => (
|
||||
<button key={t.tapId} className={styles.tapBadgeLink} onClick={() => navigateToTaps(row.processorId)} title={`Manage on route page${processorToRoute[row.processorId] ? ` (${processorToRoute[row.processorId]})` : ''}`}>
|
||||
<Badge label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />
|
||||
</button>
|
||||
))}</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')}><X size={14} /></button>
|
||||
),
|
||||
}] : []),
|
||||
], [editing, processorToRoute]);
|
||||
|
||||
// ── 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.panelToolbar}>
|
||||
<div className={styles.panelMeta}>
|
||||
Version <MonoText size="xs">{config.version}</MonoText>
|
||||
{config.updatedAt && <> · Updated <MonoText size="xs">{timeAgo(config.updatedAt)}</MonoText></>}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className={styles.panelActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<button className={styles.editBtn} onClick={startEditing} title="Edit configuration"><Pencil size={14} /></button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionHeader}>Settings</div>
|
||||
<div className={styles.settingsGrid}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>App Log Level</span>
|
||||
{editing
|
||||
? <select className={styles.select} value={String(form.applicationLogLevel)} onChange={(e) => updateField('applicationLogLevel', e.target.value)}><option value="ERROR">ERROR</option><option value="WARN">WARN</option><option value="INFO">INFO</option><option value="DEBUG">DEBUG</option><option value="TRACE">TRACE</option></select>
|
||||
: <Badge label={String(form.applicationLogLevel)} color={logLevelColor(form.applicationLogLevel as string)} variant="filled" />}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Agent Log Level</span>
|
||||
{editing
|
||||
? <select className={styles.select} value={String(form.agentLogLevel ?? 'INFO')} onChange={(e) => updateField('agentLogLevel', e.target.value)}><option value="ERROR">ERROR</option><option value="WARN">WARN</option><option value="INFO">INFO</option><option value="DEBUG">DEBUG</option><option value="TRACE">TRACE</option></select>
|
||||
: <Badge label={String(form.agentLogLevel ?? 'INFO')} color={logLevelColor(form.agentLogLevel 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}>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 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>
|
||||
</div>
|
||||
|
||||
{/* Traces & Taps */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionHeader}>Traces & Taps</div>
|
||||
<span className={styles.sectionSummary}>{tracedCount} traced · {tapCount} taps · click tap to manage</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 { appId: routeAppId } = useParams<{ appId?: string }>();
|
||||
const { data: configs } = useAllApplicationConfigs();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedApp, setSelectedApp] = useState<string | null>(routeAppId ?? null);
|
||||
const columns = useMemo(buildColumns, []);
|
||||
|
||||
// Sync from route param when it changes (sidebar navigation)
|
||||
useEffect(() => {
|
||||
if (routeAppId) setSelectedApp(routeAppId);
|
||||
}, [routeAppId]);
|
||||
|
||||
// Auto-select app from query param (e.g., ?app=caller-app)
|
||||
useEffect(() => {
|
||||
const appParam = searchParams.get('app');
|
||||
if (appParam && !selectedApp) {
|
||||
setSelectedApp(appParam);
|
||||
searchParams.delete('app');
|
||||
searchParams.delete('processor');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}
|
||||
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable<ConfigRow>
|
||||
columns={columns}
|
||||
data={(configs ?? []).map(c => ({ ...c, id: c.application }))}
|
||||
onRowClick={(row) => setSelectedApp(row.application)}
|
||||
selectedId={selectedApp ?? undefined}
|
||||
pageSize={50}
|
||||
/>
|
||||
<DetailPanel
|
||||
open={!!selectedApp}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
title={selectedApp ?? ''}
|
||||
className={styles.widePanel}
|
||||
>
|
||||
{selectedApp && <AppConfigDetail appId={selectedApp} onClose={() => setSelectedApp(null)} />}
|
||||
</DetailPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ButtonGroup } from '@cameleer/design-system';
|
||||
import type { ButtonGroupItem } from '@cameleer/design-system';
|
||||
import styles from './LevelFilterBar.module.css';
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'TRACE', label: 'Trace', color: 'var(--text-muted)' },
|
||||
{ value: 'DEBUG', label: 'Debug', color: 'var(--running)' },
|
||||
{ value: 'INFO', label: 'Info', color: 'var(--success)' },
|
||||
{ value: 'WARN', label: 'Warn', color: 'var(--warning)' },
|
||||
{ value: 'ERROR', label: 'Error', color: 'var(--error)' },
|
||||
];
|
||||
|
||||
interface LevelFilterBarProps {
|
||||
activeLevels: Set<string>;
|
||||
onChange: (levels: Set<string>) => void;
|
||||
levelCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export function LevelFilterBar({ activeLevels, onChange, levelCounts }: LevelFilterBarProps) {
|
||||
const items = LEVEL_ITEMS.map((item) => ({
|
||||
...item,
|
||||
label: `${item.label} ${formatCount(levelCounts[item.value] ?? 0)}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<ButtonGroup items={items} value={activeLevels} onChange={onChange} />
|
||||
{activeLevels.size > 0 && (
|
||||
<button
|
||||
onClick={() => onChange(new Set())}
|
||||
className={styles.clearButton}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
.entry {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.expanded {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.logger {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 8px 12px 12px 60px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 1fr;
|
||||
gap: 2px 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fullMessage {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stackTrace {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mdcSection {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mdcGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mdcEntry {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.mdcKey {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mdcValue {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: var(--amber);
|
||||
color: var(--bg-deep);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Badge } from '@cameleer/design-system';
|
||||
import type { LogEntryResponse } from '../../api/queries/logs';
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color';
|
||||
import styles from './LogEntry.module.css';
|
||||
|
||||
function levelColor(level: string): string {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'ERROR': return 'var(--error)';
|
||||
case 'WARN': return 'var(--warning)';
|
||||
case 'INFO': return 'var(--success)';
|
||||
case 'DEBUG': return 'var(--running)';
|
||||
case 'TRACE': return 'var(--text-muted)';
|
||||
default: return 'var(--text-secondary)';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
const s = String(d.getSeconds()).padStart(2, '0');
|
||||
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||
return `${h}:${m}:${s}.${ms}`;
|
||||
}
|
||||
|
||||
function abbreviateLogger(name: string | null): string {
|
||||
if (!name) return '';
|
||||
const parts = name.split('.');
|
||||
if (parts.length <= 2) return name;
|
||||
return parts.slice(0, -1).map((p) => p[0]).join('.') + '.' + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + '\u2026' : text;
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string | undefined): React.ReactNode {
|
||||
if (!query || !text) return text;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className={styles.highlight}>{text.slice(idx, idx + query.length)}</mark>
|
||||
{highlightText(text.slice(idx + query.length), query)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LogEntryProps {
|
||||
entry: LogEntryResponse;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export function LogEntry({ entry, query }: LogEntryProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasStack = !!entry.stackTrace;
|
||||
const hasExchange = !!entry.exchangeId;
|
||||
|
||||
const handleViewExchange = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!entry.exchangeId || !entry.application) return;
|
||||
const routeId = entry.mdc?.['camel.routeId'] || '_';
|
||||
navigate(`/exchanges/${entry.application}/${routeId}/${entry.exchangeId}`);
|
||||
}, [entry, navigate]);
|
||||
|
||||
const handleCopyMessage = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await navigator.clipboard.writeText(entry.message);
|
||||
}, [entry.message]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.entry} ${expanded ? styles.expanded : ''}`} onClick={() => setExpanded(!expanded)}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||
<span className={styles.level} style={{ color: levelColor(entry.level) }}>{entry.level}</span>
|
||||
{entry.application && <Badge label={entry.application} color={attributeBadgeColor(entry.application)} />}
|
||||
<span className={styles.logger} title={entry.loggerName ?? ''}>
|
||||
{abbreviateLogger(entry.loggerName)}
|
||||
</span>
|
||||
<span className={styles.message}>{highlightText(truncate(entry.message, 200), query)}</span>
|
||||
<span className={styles.chips}>
|
||||
{hasStack && <span className={styles.chip}>Stack</span>}
|
||||
{hasExchange && (
|
||||
<span className={styles.chip} onClick={handleViewExchange}>Exchange</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.detailGrid}>
|
||||
<span className={styles.detailLabel}>Logger</span>
|
||||
<span className={styles.detailValue}>{entry.loggerName}</span>
|
||||
<span className={styles.detailLabel}>Thread</span>
|
||||
<span className={styles.detailValue}>{entry.threadName}</span>
|
||||
<span className={styles.detailLabel}>Instance</span>
|
||||
<span className={styles.detailValue}>{entry.instanceId}</span>
|
||||
{hasExchange && (
|
||||
<>
|
||||
<span className={styles.detailLabel}>Exchange</span>
|
||||
<span className={styles.detailValue}>
|
||||
<button className={styles.linkBtn} onClick={handleViewExchange}>
|
||||
{entry.exchangeId}
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.fullMessage}>{highlightText(entry.message, query)}</div>
|
||||
|
||||
{hasStack && (
|
||||
<pre className={styles.stackTrace}>{highlightText(entry.stackTrace!, query)}</pre>
|
||||
)}
|
||||
|
||||
{entry.mdc && Object.keys(entry.mdc).length > 0 && (
|
||||
<div className={styles.mdcSection}>
|
||||
<span className={styles.detailLabel}>MDC</span>
|
||||
<div className={styles.mdcGrid}>
|
||||
{Object.entries(entry.mdc).map(([k, v]) => (
|
||||
<div key={k} className={styles.mdcEntry}>
|
||||
<span className={styles.mdcKey}>{k}</span>
|
||||
<span className={styles.mdcValue}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{hasExchange && (
|
||||
<button className={styles.actionBtn} onClick={handleViewExchange}>
|
||||
View Exchange
|
||||
</button>
|
||||
)}
|
||||
<button className={styles.actionBtn} onClick={handleCopyMessage}>
|
||||
Copy Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.liveTailBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liveTailBtn:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.liveTailActive {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadingWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.loadMoreBtn {
|
||||
padding: 6px 20px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.loadMoreBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loadMoreBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.newEntries {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: var(--amber);
|
||||
color: var(--bg-deep);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.fetchDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.scope {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Spinner, EmptyState, useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useLogs } from '../../api/queries/logs';
|
||||
import { useRefreshInterval } from '../../api/queries/use-refresh-interval';
|
||||
import { LevelFilterBar } from './LevelFilterBar';
|
||||
import { LogEntry } from './LogEntry';
|
||||
import styles from './LogSearch.module.css';
|
||||
|
||||
interface LogSearchProps {
|
||||
defaultApplication?: string;
|
||||
defaultRouteId?: string;
|
||||
}
|
||||
|
||||
export function LogSearch({ defaultApplication, defaultRouteId }: LogSearchProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
|
||||
// Initialize from URL params (for cross-navigation)
|
||||
const urlExchangeId = searchParams.get('exchangeId') ?? undefined;
|
||||
const urlQ = searchParams.get('q') ?? undefined;
|
||||
|
||||
const [query, setQuery] = useState(urlQ ?? '');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(urlQ ?? '');
|
||||
const [activeLevels, setActiveLevels] = useState<Set<string>>(new Set());
|
||||
const [liveTail, setLiveTail] = useState(false);
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [allEntries, setAllEntries] = useState<any[]>([]);
|
||||
|
||||
const liveTailRef = useRef(liveTail);
|
||||
liveTailRef.current = liveTail;
|
||||
|
||||
// Debounce search query
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedQuery(value);
|
||||
setCursor(undefined);
|
||||
setAllEntries([]);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// Reset pagination when filters change
|
||||
const handleLevelChange = useCallback((levels: Set<string>) => {
|
||||
setActiveLevels(levels);
|
||||
setCursor(undefined);
|
||||
setAllEntries([]);
|
||||
}, []);
|
||||
|
||||
const levelCsv = useMemo(() =>
|
||||
activeLevels.size > 0 ? [...activeLevels].join(',') : undefined,
|
||||
[activeLevels]);
|
||||
|
||||
// Build search params
|
||||
const latestTsRef = useRef<string | undefined>(undefined);
|
||||
const liveRefetch = useRefreshInterval(2_000);
|
||||
|
||||
const searchParamsObj = useMemo(() => ({
|
||||
q: debouncedQuery || undefined,
|
||||
level: levelCsv,
|
||||
application: defaultApplication,
|
||||
exchangeId: urlExchangeId,
|
||||
from: liveTail
|
||||
? (latestTsRef.current ?? timeRange.start.toISOString())
|
||||
: timeRange.start.toISOString(),
|
||||
to: liveTail ? new Date().toISOString() : timeRange.end.toISOString(),
|
||||
cursor: liveTail ? undefined : cursor,
|
||||
limit: liveTail ? 200 : 100,
|
||||
sort: liveTail ? 'asc' as const : 'desc' as const,
|
||||
}), [debouncedQuery, levelCsv, defaultApplication, urlExchangeId,
|
||||
timeRange, cursor, liveTail]);
|
||||
|
||||
const { data, isLoading, isFetching } = useLogs(searchParamsObj, {
|
||||
refetchInterval: liveTail ? liveRefetch : undefined,
|
||||
});
|
||||
|
||||
// Live tail: append new entries
|
||||
useEffect(() => {
|
||||
if (!data || !liveTail) return;
|
||||
if (data.data.length > 0) {
|
||||
setAllEntries((prev) => {
|
||||
const combined = [...prev, ...data.data];
|
||||
// Buffer limit: keep last 5000
|
||||
return combined.length > 5000 ? combined.slice(-5000) : combined;
|
||||
});
|
||||
latestTsRef.current = data.data[data.data.length - 1].timestamp;
|
||||
}
|
||||
}, [data, liveTail]);
|
||||
|
||||
// Auto-scroll for live tail
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveTail && autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [allEntries, liveTail, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollRef.current || !liveTail) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50);
|
||||
}, [liveTail]);
|
||||
|
||||
const handleToggleLiveTail = useCallback(() => {
|
||||
setLiveTail((prev) => {
|
||||
if (!prev) {
|
||||
// Entering live tail
|
||||
setAllEntries([]);
|
||||
setCursor(undefined);
|
||||
latestTsRef.current = undefined;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (data?.nextCursor) {
|
||||
setCursor(data.nextCursor);
|
||||
}
|
||||
}, [data?.nextCursor]);
|
||||
|
||||
// Accumulate pages for non-live mode
|
||||
useEffect(() => {
|
||||
if (liveTail || !data) return;
|
||||
if (cursor) {
|
||||
// Appending a new page
|
||||
setAllEntries((prev) => [...prev, ...data.data]);
|
||||
} else {
|
||||
// Fresh search
|
||||
setAllEntries(data.data);
|
||||
}
|
||||
}, [data, cursor, liveTail]);
|
||||
|
||||
const entries = liveTail ? allEntries : allEntries;
|
||||
const levelCounts = data?.levelCounts ?? {};
|
||||
const hasMore = data?.hasMore ?? false;
|
||||
const newEntriesCount = liveTail && !autoScroll && data?.data.length
|
||||
? data.data.length : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.searchRow}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
<button
|
||||
className={`${styles.liveTailBtn} ${liveTail ? styles.liveTailActive : ''}`}
|
||||
onClick={handleToggleLiveTail}
|
||||
>
|
||||
{liveTail && <span className={styles.liveDot} />}
|
||||
{liveTail ? 'LIVE TAIL' : 'Live Tail: OFF'}
|
||||
</button>
|
||||
</div>
|
||||
<LevelFilterBar
|
||||
activeLevels={activeLevels}
|
||||
onChange={handleLevelChange}
|
||||
levelCounts={levelCounts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.results}
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isLoading && entries.length === 0 ? (
|
||||
<div className={styles.loadingWrap}>
|
||||
<Spinner size="md" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No logs found"
|
||||
description={debouncedQuery || activeLevels.size > 0
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No log entries in the selected time range.'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{entries.map((entry, i) => (
|
||||
<LogEntry key={`${entry.timestamp}-${i}`} entry={entry} query={debouncedQuery || undefined} />
|
||||
))}
|
||||
{!liveTail && hasMore && (
|
||||
<div className={styles.loadMore}>
|
||||
<button
|
||||
className={styles.loadMoreBtn}
|
||||
onClick={handleLoadMore}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{liveTail && !autoScroll && newEntriesCount > 0 && (
|
||||
<div className={styles.newEntries} onClick={() => setAutoScroll(true)}>
|
||||
New entries arriving — click to scroll to bottom
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.statusBar}>
|
||||
<span>{entries.length} entries{liveTail ? ' (live)' : ''}</span>
|
||||
{isFetching && <span className={styles.fetchDot} />}
|
||||
{defaultApplication && (
|
||||
<span className={styles.scope}>App: {defaultApplication}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { LogSearch } from './LogSearch';
|
||||
|
||||
export default function LogsPage() {
|
||||
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
|
||||
return <LogSearch defaultApplication={appId} defaultRouteId={routeId} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./vite.config.ts","./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user