refactor(decnet_web/AttackerDetail): extract TimelineSection
Lift the TIMELINE collapsible (timestamps, ASN, reverse DNS, leaked-IPs row with rotation detection) into its own section. LeakedIPsRow + the rotation/inline-limit constants come along since they were only ever used here. Also moves the shared `Section` collapsible primitive into AttackerDetail/ui.tsx so the remaining sections can adopt the template without re-importing through the parent module. - New AttackerDetail/sections/TimelineSection.tsx (LeakedIPsRow inline as a private helper) - AttackerDetail/ui.tsx now exports both Tag and Section - AttackerDetail.tsx loses LeakedIPsRow, the Section helper, the Timeline JSX block, and now-unused imports (ChevronUp, ChevronDown, AttackerData) - TimelineSection.test.tsx covers timestamps, unknown-origin path, rotation badge, empty leaks, collapse, and toggle callback
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Activity, AlertTriangle, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
|
||||
import { Activity, AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import ArtifactDrawer from './ArtifactDrawer';
|
||||
import MailDrawer from './MailDrawer';
|
||||
@@ -10,9 +10,9 @@ import TTPsObservedSection from './TTPsObservedSection';
|
||||
import { useAttackerDetail } from './AttackerDetail/useAttackerDetail';
|
||||
import { AttackerHeader } from './AttackerDetail/sections/AttackerHeader';
|
||||
import { AttackerStats } from './AttackerDetail/sections/AttackerStats';
|
||||
import { Tag } from './AttackerDetail/ui';
|
||||
import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
|
||||
import { Tag, Section } from './AttackerDetail/ui';
|
||||
import type {
|
||||
AttackerData,
|
||||
AttackerBehavior,
|
||||
BehaviouralObservation,
|
||||
AttributionPrimitiveState,
|
||||
@@ -1008,144 +1008,6 @@ export const BehaviouralPrimitivesPanel: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Collapsible section ────────────────────────────────────────────────────
|
||||
|
||||
const Section: React.FC<{
|
||||
title: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, right, open, onToggle, children }) => (
|
||||
<div className="logs-section">
|
||||
<div
|
||||
className="section-header"
|
||||
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Leaked-IPs row (truncated view + rotation-detection badge) ────────────
|
||||
|
||||
const ROTATION_THRESHOLD = 20;
|
||||
const INLINE_LIMIT = 1;
|
||||
|
||||
interface LeakedIPsRowProps {
|
||||
leaks: NonNullable<AttackerData['ip_leaks']>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ leaks, total }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const distinctIPs = Array.from(
|
||||
new Set(
|
||||
leaks
|
||||
.map((l) => l.payload?.real_ip_claim)
|
||||
.filter((v): v is string => !!v),
|
||||
),
|
||||
);
|
||||
const rotationDetected = total >= ROTATION_THRESHOLD;
|
||||
const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
|
||||
const hiddenInList = distinctIPs.length - visible.length;
|
||||
// Backend caps server-side leaks at 10 rows; "total" is the unbounded
|
||||
// count — may exceed what we actually have IP values for.
|
||||
const remainingBeyondSample = total - distinctIPs.length;
|
||||
|
||||
const ipTooltip = (ip: string): string => {
|
||||
const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
|
||||
return latest
|
||||
? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
|
||||
: '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
|
||||
LEAKED IPs:{' '}
|
||||
</span>
|
||||
{rotationDetected && (
|
||||
<span
|
||||
style={{ marginRight: 8, display: 'inline-block' }}
|
||||
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
|
||||
>
|
||||
<Tag color="var(--alert, #ff4d4d)">
|
||||
ROTATION · {total}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
{visible.map((ip, i, arr) => (
|
||||
<span
|
||||
key={ip}
|
||||
style={{
|
||||
color: 'var(--warn, #e0a040)',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
title={ipTooltip(ip)}
|
||||
>
|
||||
{ip}
|
||||
{i < arr.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hiddenInList > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--warn, #e0a040)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
+ {hiddenInList} more
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{remainingBeyondSample > 0 && (
|
||||
<span
|
||||
className="dim"
|
||||
style={{ marginLeft: 6, fontSize: '0.75rem' }}
|
||||
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
|
||||
>
|
||||
(+{remainingBeyondSample} beyond sample)
|
||||
</span>
|
||||
)}
|
||||
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--accent-color)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
collapse
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
|
||||
@@ -1464,78 +1326,11 @@ const AttackerDetail: React.FC = () => {
|
||||
{/* TTPs Observed (per-IP slice) — see TTP_TAGGING.md §"UI surface" */}
|
||||
<TTPsObservedSection scope="attacker" uuid={attacker.uuid} />
|
||||
|
||||
{/* Timestamps */}
|
||||
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
|
||||
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '32px', fontSize: '0.85rem' }}>
|
||||
<div>
|
||||
<span className="dim">FIRST SEEN: </span>
|
||||
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">LAST SEEN: </span>
|
||||
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">UPDATED: </span>
|
||||
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">ORIGIN: </span>
|
||||
{attacker.country_code ? (
|
||||
<span className="matrix-text">
|
||||
{attacker.country_code}
|
||||
{attacker.country_source && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
({attacker.country_source})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">AS: </span>
|
||||
{attacker.asn != null ? (
|
||||
<span className="matrix-text">
|
||||
AS{attacker.asn}
|
||||
{attacker.as_name && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
{attacker.as_name}
|
||||
</span>
|
||||
)}
|
||||
{attacker.asn_source && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
({attacker.asn_source})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">REVERSE DNS: </span>
|
||||
{attacker.ptr_record ? (
|
||||
<span
|
||||
className="matrix-text"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
title="One-shot PTR record resolved at first sighting."
|
||||
>
|
||||
{attacker.ptr_record}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">—</span>
|
||||
)}
|
||||
</div>
|
||||
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
|
||||
<LeakedIPsRow
|
||||
leaks={attacker.ip_leaks}
|
||||
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<TimelineSection
|
||||
attacker={attacker}
|
||||
open={openSections.timeline}
|
||||
onToggle={() => toggle('timeline')}
|
||||
/>
|
||||
|
||||
{/* Services */}
|
||||
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { makeAttacker } from '../../../test/fixtures';
|
||||
import { TimelineSection } from './TimelineSection';
|
||||
|
||||
describe('TimelineSection', () => {
|
||||
it('renders the labelled timestamp + ASN + reverse-DNS rows', () => {
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker({
|
||||
asn: 12345,
|
||||
as_name: 'EXAMPLE-AS',
|
||||
asn_source: 'ipinfo',
|
||||
ptr_record: 'host.example.com',
|
||||
})}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('FIRST SEEN:')).toBeInTheDocument();
|
||||
expect(screen.getByText('LAST SEEN:')).toBeInTheDocument();
|
||||
expect(screen.getByText(/AS12345/)).toBeInTheDocument();
|
||||
expect(screen.getByText('host.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "unknown" when origin and ASN are absent', () => {
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker({ country_code: null, asn: null })}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('unknown')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders the ROTATION badge when ip_leaks total >= 20', () => {
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker({
|
||||
ip_leaks: [
|
||||
{
|
||||
timestamp: '2026-05-01T10:00:00Z',
|
||||
bounty_type: 'xff_leak',
|
||||
payload: { real_ip_claim: '1.1.1.1', source_header: 'X-Forwarded-For' },
|
||||
},
|
||||
],
|
||||
ip_leaks_total: 42,
|
||||
})}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/ROTATION · 42/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render leaked-IPs row when ip_leaks is empty', () => {
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker()}
|
||||
open={true}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/LEAKED IPs/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides body content when open=false', () => {
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker()}
|
||||
open={false}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('FIRST SEEN:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onToggle when the section header is clicked', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TimelineSection
|
||||
attacker={makeAttacker()}
|
||||
open={true}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText('TIMELINE'));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Section, Tag } from '../ui';
|
||||
import type { AttackerData } from '../types';
|
||||
|
||||
const ROTATION_THRESHOLD = 20;
|
||||
const INLINE_LIMIT = 1;
|
||||
|
||||
interface LeakedIPsRowProps {
|
||||
leaks: NonNullable<AttackerData['ip_leaks']>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** "LEAKED IPs" inline list with rotation-detection badge. The
|
||||
* backend caps the server-side sample at 10 rows; `total` is the
|
||||
* unbounded count, which may exceed what we have actual IP values
|
||||
* for. Past ROTATION_THRESHOLD distinct claims, badge in red — that
|
||||
* pattern is XFF-rotation / WAF-bypass probing, not real
|
||||
* attribution leakage. */
|
||||
const LeakedIPsRow: React.FC<LeakedIPsRowProps> = ({ leaks, total }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const distinctIPs = Array.from(
|
||||
new Set(
|
||||
leaks
|
||||
.map((l) => l.payload?.real_ip_claim)
|
||||
.filter((v): v is string => !!v),
|
||||
),
|
||||
);
|
||||
const rotationDetected = total >= ROTATION_THRESHOLD;
|
||||
const visible = expanded ? distinctIPs : distinctIPs.slice(0, INLINE_LIMIT);
|
||||
const hiddenInList = distinctIPs.length - visible.length;
|
||||
const remainingBeyondSample = total - distinctIPs.length;
|
||||
|
||||
const ipTooltip = (ip: string): string => {
|
||||
const latest = leaks.find((l) => l.payload?.real_ip_claim === ip);
|
||||
return latest
|
||||
? `Leaked via ${latest.payload.source_header ?? '?'}; source ${latest.payload.source_ip ?? '?'}`
|
||||
: '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="dim" style={{ color: 'var(--warn, #e0a040)' }}>
|
||||
LEAKED IPs:{' '}
|
||||
</span>
|
||||
{rotationDetected && (
|
||||
<span
|
||||
style={{ marginRight: 8, display: 'inline-block' }}
|
||||
title={`${total} distinct claimed IPs — almost certainly XFF-rotation / WAF-bypass probing, not a real attribution leak.`}
|
||||
>
|
||||
<Tag color="var(--alert, #ff4d4d)">
|
||||
ROTATION · {total}
|
||||
</Tag>
|
||||
</span>
|
||||
)}
|
||||
{visible.map((ip, i, arr) => (
|
||||
<span
|
||||
key={ip}
|
||||
style={{ color: 'var(--warn, #e0a040)', fontFamily: 'monospace' }}
|
||||
title={ipTooltip(ip)}
|
||||
>
|
||||
{ip}
|
||||
{i < arr.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hiddenInList > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--warn, #e0a040)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
+ {hiddenInList} more
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{remainingBeyondSample > 0 && (
|
||||
<span
|
||||
className="dim"
|
||||
style={{ marginLeft: 6, fontSize: '0.75rem' }}
|
||||
title="Only the 10 most-recent claimed IPs are fetched; the total count is the full DB tally."
|
||||
>
|
||||
(+{remainingBeyondSample} beyond sample)
|
||||
</span>
|
||||
)}
|
||||
{expanded && hiddenInList === 0 && distinctIPs.length > INLINE_LIMIT && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--accent-color)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontFamily: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
collapse
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
attacker: AttackerData;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** TIMELINE collapsible — first/last seen, ASN/origin, reverse DNS,
|
||||
* and leaked-IP row when the attacker has any ip_leaks. */
|
||||
export const TimelineSection: React.FC<Props> = ({ attacker, open, onToggle }) => (
|
||||
<Section title="TIMELINE" open={open} onToggle={onToggle}>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '32px',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="dim">FIRST SEEN: </span>
|
||||
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">LAST SEEN: </span>
|
||||
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">UPDATED: </span>
|
||||
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">ORIGIN: </span>
|
||||
{attacker.country_code ? (
|
||||
<span className="matrix-text">
|
||||
{attacker.country_code}
|
||||
{attacker.country_source && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
({attacker.country_source})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">AS: </span>
|
||||
{attacker.asn != null ? (
|
||||
<span className="matrix-text">
|
||||
AS{attacker.asn}
|
||||
{attacker.as_name && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
{attacker.as_name}
|
||||
</span>
|
||||
)}
|
||||
{attacker.asn_source && (
|
||||
<span className="dim" style={{ marginLeft: 6, fontSize: '0.75rem' }}>
|
||||
({attacker.asn_source})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="dim">REVERSE DNS: </span>
|
||||
{attacker.ptr_record ? (
|
||||
<span
|
||||
className="matrix-text"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
title="One-shot PTR record resolved at first sighting."
|
||||
>
|
||||
{attacker.ptr_record}
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">—</span>
|
||||
)}
|
||||
</div>
|
||||
{attacker.ip_leaks && attacker.ip_leaks.length > 0 && (
|
||||
<LeakedIPsRow
|
||||
leaks={attacker.ip_leaks}
|
||||
total={attacker.ip_leaks_total ?? attacker.ip_leaks.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronUp } from '../../icons';
|
||||
|
||||
/** Pill-style tag chip used throughout the AttackerDetail surface
|
||||
* for badges, filters, and category labels. Color drives both the
|
||||
@@ -20,3 +21,30 @@ export const Tag: React.FC<{ children: React.ReactNode; color?: string }> = ({
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Collapsible panel used by every section on the AttackerDetail page.
|
||||
* The header is the toggle; an optional `right` slot hosts controls
|
||||
* (filters, action buttons) whose clicks are stopped from bubbling
|
||||
* to the toggle handler. */
|
||||
export const Section: React.FC<{
|
||||
title: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, right, open, onToggle, children }) => (
|
||||
<div className="logs-section">
|
||||
<div
|
||||
className="section-header"
|
||||
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user