chore: merge executor worktree (worktree-agent-a972575d901be6c4f)
# Conflicts: # .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md
This commit is contained in:
commit
4f976b741f
5 changed files with 425 additions and 14 deletions
|
|
@ -117,14 +117,14 @@ destructive remediation gated behind explicit human approval.
|
|||
(LiveLink supplies the ticket ID as dynamic content, not the internal
|
||||
campaign UUID), authenticated via the existing Better Auth session only —
|
||||
no separate token or query-param auth scheme
|
||||
- [ ] **REVIEW-02**: The page displays the campaign's timeline — linked
|
||||
- [x] **REVIEW-02**: The page displays the campaign's timeline — linked
|
||||
reports, classification history, and audit events (classify/approve/
|
||||
remediate/mark-false-positive) — in chronological order
|
||||
- [ ] **REVIEW-03**: The page displays the gathered evidence — parsed EML
|
||||
headers/URLs/attachments, sanitized body preview, and Mimecast blast-radius
|
||||
data (including an explicit `unavailable` state when Mimecast isn't
|
||||
configured) — never rendering a raw/unsanitized body or unredacted secrets
|
||||
- [ ] **REVIEW-04**: The page displays the current classification (SPAM/
|
||||
- [x] **REVIEW-04**: The page displays the current classification (SPAM/
|
||||
UNWANTED/THREAT), confidence, reasons, and recommended remediation
|
||||
action(s)
|
||||
- [ ] **REVIEW-05**: An operator can approve, remediate, or mark a campaign as
|
||||
|
|
@ -204,9 +204,9 @@ Populated during roadmap creation.
|
|||
| NOTE-01 | Phase 21 | Complete |
|
||||
| ACCESS-01 | Phase 18 | Complete |
|
||||
| REVIEW-01 | Phase 22 | Pending |
|
||||
| REVIEW-02 | Phase 22 | Pending |
|
||||
| REVIEW-02 | Phase 22 | Complete |
|
||||
| REVIEW-03 | Phase 22 | Pending |
|
||||
| REVIEW-04 | Phase 22 | Pending |
|
||||
| REVIEW-04 | Phase 22 | Complete |
|
||||
| REVIEW-05 | Phase 22 | Pending |
|
||||
| REVIEW-06 | Phase 22 | Pending |
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
phase: 22-approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
plan: 04
|
||||
subsystem: ui
|
||||
tags: [react, nextjs, shadcn, phishing, tailwind, lucide]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 22-01/02/03
|
||||
provides: campaign detail data shape (classification + timeline arrays) these components render
|
||||
provides:
|
||||
- "ClassificationCard — read-only latest-verdict display with Reclassify action (REVIEW-04)"
|
||||
- "TimelineCard — chronological merged-event renderer (REVIEW-02)"
|
||||
affects: [22-05, 22-06]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "CardTitle className=\"font-bold\" override for the 700-weight card heading (UI-SPEC Typography contract)"
|
||||
- "Client-side hasPermission(role, 'phishing', <action>) gate via useSession(), hiding (not disabling) the affordance"
|
||||
- "Null-classification early-return guard — page-level empty states (plan 06) own the replacement UI, not the card"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- components/phishing/classification-card.tsx
|
||||
- components/phishing/timeline-card.tsx
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "TimelineCard defines its own local TimelineEntry union (per plan's fallback instruction) rather than importing from plan 02, since plan 04 has no depends_on and plan 02's extended detail route may not exist in this worktree yet."
|
||||
- "Audit event_type 'campaign_classified' is rendered with the same Sparkles/verdict-tinted treatment as 'classification' kind entries (per the plan's <interfaces> note: \"campaign_classified → classification entries\"), distinct from the generic humanized fallback used for any other/future event_type."
|
||||
- "remediation_approved action count derived defensively from payload.actionIds.length, falling back to payload.actions.length, then 0 — matches the two audit-payload shapes actually written by lib/services/remediation-service.ts."
|
||||
|
||||
patterns-established:
|
||||
- "Timeline rail: 8px h-2 w-2 rounded-full dot + 1px border-l connector, built with plain divs/Tailwind (UI-SPEC explicitly calls this a one-off, not a new primitive)."
|
||||
|
||||
requirements-completed: [REVIEW-02, REVIEW-04]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 22 Plan 04: ClassificationCard + TimelineCard Summary
|
||||
|
||||
**Two read-only presentational components — ClassificationCard (verdict/confidence/reasons/action-chips + approval warning, Reclassify gated on `phishing:analyze`) and TimelineCard (chronological reports+classifications+audit-events merge) — built per the UI-SPEC color/typography contract with zero new dependencies.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~12 min
|
||||
- **Started:** 2026-07-16T18:18:00Z
|
||||
- **Completed:** 2026-07-16T18:29:48Z
|
||||
- **Tasks:** 2/2 completed
|
||||
- **Files modified:** 2 created
|
||||
|
||||
## Accomplishments
|
||||
- `ClassificationCard` renders the latest classification's verdict badge, confidence, summary, reasons list, and informational recommended-action chips; shows an amber `Alert` when `requiresApproval` is true; hides (not disables) the Reclassify button unless the session role has `phishing:analyze`; returns `null` when `classification` is missing so the review page's empty states (plan 06) own that surface.
|
||||
- `TimelineCard` merges `report` / `classification` / `audit` timeline entries into a single ascending vertical list (relies on server ordering, never calls `.sort()`), with a per-kind icon/label/tint mapping including the full `audit_events.event_type` table from the UI-SPEC (`remediation_approved`, `remediation_completed`, `campaign_marked_false_positive`, `campaign_classified`, and a humanized fallback for anything else).
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: ClassificationCard (REVIEW-04)** - `14adddf` (feat)
|
||||
2. **Task 2: TimelineCard (REVIEW-02)** - `e990a32` (feat)
|
||||
|
||||
**Plan metadata:** commit pending (this SUMMARY + REQUIREMENTS)
|
||||
|
||||
## Files Created/Modified
|
||||
- `components/phishing/classification-card.tsx` - `ClassificationCard({ campaignId, classification, onReclassified })` — verdict `StatusBadge`, confidence, summary, reasons `<ul>`, recommended-action chips, requires-approval `Alert`, permission-gated Reclassify button that POSTs `/api/phishing/campaigns/{id}/classify`.
|
||||
- `components/phishing/timeline-card.tsx` - `TimelineCard({ timeline })` — locally-defined `TimelineEntry` union, per-kind rendering, 8px rail-dot + `border-l` layout, `date-fns` `formatDistanceToNow` for relative timestamps with the absolute value in a `title` attribute.
|
||||
|
||||
## Decisions Made
|
||||
- Defined `TimelineEntry` locally in `timeline-card.tsx` rather than importing a shared type, since this plan has `depends_on: []` and plan 02's extended detail route (which shapes this array) is not guaranteed to exist yet in a parallel-wave worktree. The union mirrors the plan's `<interfaces>` block exactly (`kind: 'report' | 'classification' | 'audit'`).
|
||||
- Treated audit `event_type: 'campaign_classified'` as its own case (Sparkles icon, verdict-tinted per `payload.verdict`) rather than falling through to the generic "any other" fallback, per the plan's explicit `<interfaces>` note ("campaign_classified → classification entries").
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — plan executed exactly as written. One out-of-scope, pre-existing test failure was discovered while running the plan's `npm test` verification step and logged (not fixed) per the executor's scope-boundary rule — see `## Deferred Issues` below.
|
||||
|
||||
## Deferred Issues
|
||||
|
||||
- `lib/services/analyzer/itglue-search.test.ts` has 2 failing assertions (`docs.length` mismatches in "tolerates per-call failures" tests). This file is unrelated to either component built in this plan and was not modified by any phase-22 plan. Logged to `.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/deferred-items.md`; not fixed (out of scope). All other 394 tests pass.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `npx tsc --noEmit --pretty` — clean, no errors anywhere in the repo (including both new files).
|
||||
- `npm test` — 394/396 passing; the 2 pre-existing failures are unrelated to this plan (see Deferred Issues above).
|
||||
- Task-level acceptance criteria (grep checks for `hasPermission`, `font-bold`, `requiresApproval`, zero `Checkbox` occurrences, `h-2 w-2`, `remediation_completed`, `border-l`, zero `.sort(` occurrences) all passed as specified in the plan.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — both components are fully wired to the props/interfaces specified in the plan; no hardcoded empty values or placeholder text.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — both components render all classification/audit content as JSX text (React auto-escaping, no `dangerouslySetInnerHTML`), matching threat register items T-22-10 (XSS mitigation) and T-22-11 (Reclassify button gated client-side on `hasPermission`, with the server route independently enforcing the same permission per the plan's threat model).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
|
@ -2,15 +2,14 @@
|
|||
|
||||
Pre-existing failures/warnings discovered during execution but out of scope
|
||||
for the current plan (not touched by this plan's files, not caused by this
|
||||
plan's changes).
|
||||
plan's changes). Logged, not fixed, per the executor's scope-boundary rule.
|
||||
|
||||
## Plan 01
|
||||
## Pre-existing `itglue-search.test.ts` failures
|
||||
|
||||
- `lib/services/analyzer/itglue-search.test.ts` — 2 pre-existing failing
|
||||
tests (`itglueSearch > tolerates per-call failures ...`, doc count
|
||||
mismatches). Unrelated to phishing-ticket-resolver /
|
||||
remediation-default-params / phishing-timeline (this plan's files). Not
|
||||
modified by any commit in this plan. Confirmed present before this plan's
|
||||
changes (file untouched in `git diff`). Full-suite run: `2 failed | 411
|
||||
passed (413)` with only these 2 pre-existing failures — all other tests,
|
||||
including the 3 new files added by this plan, are green.
|
||||
- **File:** `lib/services/analyzer/itglue-search.test.ts`
|
||||
- **Symptom:** 2 failing assertions —
|
||||
- `itglueSearch > tolerates per-call failures (flex asset errors, configurations still returns)`: `expect(result.docs.length).toBe(2)` received `1`
|
||||
- `itglueSearch > tolerates per-call failures (configurations errors, flex still returns)`: `expect(result.docs.length).toBe(1)` received `0`
|
||||
- **Discovered during:** Plan 22-01 (`phishing-ticket-resolver` / `remediation-default-params` / `phishing-timeline`) and independently confirmed during Plan 22-04 (`ClassificationCard` + `TimelineCard`).
|
||||
- **Scope:** Unrelated to any phase-22 plan's files. Not modified by any commit in this phase. Confirmed present before any phase-22 changes.
|
||||
- **Action:** Not fixed — out of scope per executor scope-boundary rules. Full-suite run: `2 failed | 411 passed (413)` — the only failures are these 2 pre-existing ones; all other tests, including new files added by phase-22 plans, are green. `npx tsc --noEmit --pretty` is clean.
|
||||
|
|
|
|||
161
components/phishing/classification-card.tsx
Normal file
161
components/phishing/classification-card.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Sparkles, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
|
||||
export interface ClassificationCardData {
|
||||
id: string;
|
||||
verdict: 'SPAM' | 'UNWANTED' | 'THREAT';
|
||||
confidence: string | null;
|
||||
summary: string | null;
|
||||
reasons: string[];
|
||||
recommendedActions: string[];
|
||||
requiresApproval: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ClassificationCardProps {
|
||||
campaignId: string;
|
||||
classification: ClassificationCardData | null;
|
||||
onReclassified: () => void;
|
||||
}
|
||||
|
||||
const VERDICT_VARIANT_CLASS: Record<ClassificationCardData['verdict'], string> = {
|
||||
SPAM: 'bg-slate-500/15 text-slate-600',
|
||||
UNWANTED: 'bg-amber-500/15 text-amber-600',
|
||||
THREAT: 'bg-destructive/15 text-destructive',
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
block_sender: 'Block sender',
|
||||
purge_message: 'Purge message',
|
||||
warn_user: 'Warn user',
|
||||
no_action: 'No action',
|
||||
reset_password: 'Reset password',
|
||||
isolate_endpoint: 'Isolate endpoint',
|
||||
disable_forwarding_rule: 'Disable forwarding rule',
|
||||
};
|
||||
|
||||
function humanizeAction(actionType: string): string {
|
||||
return (
|
||||
ACTION_LABEL[actionType] ??
|
||||
actionType
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
export function ClassificationCard({
|
||||
campaignId,
|
||||
classification,
|
||||
onReclassified,
|
||||
}: ClassificationCardProps) {
|
||||
const { data: session } = useSession();
|
||||
const [isReclassifying, setIsReclassifying] = useState(false);
|
||||
const role = (session?.user as { role?: string } | undefined)?.role ?? 'user';
|
||||
const canReclassify = hasPermission(role, 'phishing', 'analyze');
|
||||
|
||||
// Guard: this card renders nothing for a null classification. The review
|
||||
// page (not this card) chooses the replacement UI for both null-classification
|
||||
// paths — the default "grouped but not yet classified" empty state (plan 06)
|
||||
// and the D-08 ungrouped-report Alert (plan 06) — so we never dereference
|
||||
// classification.verdict/.reasons/etc. here.
|
||||
if (classification == null) return null;
|
||||
|
||||
async function handleReclassify() {
|
||||
setIsReclassifying(true);
|
||||
try {
|
||||
const res = await fetch(`/api/phishing/campaigns/${campaignId}/classify`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Reclassify failed');
|
||||
toast.success('Ticket reclassified');
|
||||
onReclassified();
|
||||
} catch (err) {
|
||||
toast.error(`Reclassify failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsReclassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<CardTitle className="font-bold">
|
||||
<Sparkles className="h-4 w-4 mr-2 inline" />
|
||||
Classification
|
||||
</CardTitle>
|
||||
{canReclassify && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isReclassifying}
|
||||
onClick={handleReclassify}
|
||||
>
|
||||
{isReclassifying ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : null}
|
||||
Reclassify ticket
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge variantClass={VERDICT_VARIANT_CLASS[classification.verdict]}>
|
||||
{classification.verdict}
|
||||
</StatusBadge>
|
||||
{classification.confidence != null && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{classification.confidence}% confidence
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{classification.summary && (
|
||||
<p className="text-sm">{classification.summary}</p>
|
||||
)}
|
||||
|
||||
{classification.reasons.length > 0 && (
|
||||
<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">
|
||||
{classification.reasons.map((reason, idx) => (
|
||||
<li key={idx}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{classification.recommendedActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classification.recommendedActions.map((actionType) => (
|
||||
<StatusBadge
|
||||
key={actionType}
|
||||
variantClass="bg-slate-500/15 text-slate-600"
|
||||
>
|
||||
{humanizeAction(actionType)}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{classification.requiresApproval && (
|
||||
<Alert className="border-amber-500/50 text-amber-600 dark:border-amber-500 [&>svg]:text-amber-600">
|
||||
<AlertDescription>
|
||||
This classification recommends a destructive action and requires
|
||||
explicit approval before remediation can proceed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
153
components/phishing/timeline-card.tsx
Normal file
153
components/phishing/timeline-card.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
'use client';
|
||||
|
||||
import { FileText, Sparkles, CheckCircle2, ShieldCheck, XCircle, Circle } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TimelineEntry =
|
||||
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
|
||||
| { kind: 'classification'; at: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: string | null }
|
||||
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
|
||||
|
||||
interface TimelineCardProps {
|
||||
timeline: TimelineEntry[];
|
||||
}
|
||||
|
||||
const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT', string> = {
|
||||
SPAM: 'bg-slate-500 text-slate-600',
|
||||
UNWANTED: 'bg-amber-500 text-amber-600',
|
||||
THREAT: 'bg-destructive text-destructive',
|
||||
};
|
||||
|
||||
function humanizeEventType(eventType: string): string {
|
||||
return eventType
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function renderEntry(entry: TimelineEntry): {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
dotClass: string;
|
||||
textClass: string;
|
||||
} {
|
||||
if (entry.kind === 'report') {
|
||||
const ticketLabel = entry.ticketNumber ? `Ticket #${entry.ticketNumber}` : 'Ticket';
|
||||
const company = entry.companyName ? ` (${entry.companyName})` : '';
|
||||
return {
|
||||
label: `Report linked — ${ticketLabel}${company}`,
|
||||
icon: FileText,
|
||||
dotClass: 'bg-muted-foreground',
|
||||
textClass: 'text-muted-foreground',
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.kind === 'classification') {
|
||||
const confidence = entry.confidence != null ? ` (${entry.confidence}%)` : '';
|
||||
return {
|
||||
label: `Classified as ${entry.verdict}${confidence}`,
|
||||
icon: Sparkles,
|
||||
dotClass: VERDICT_TINT[entry.verdict].split(' ')[0],
|
||||
textClass: VERDICT_TINT[entry.verdict].split(' ')[1],
|
||||
};
|
||||
}
|
||||
|
||||
// entry.kind === 'audit'
|
||||
const payload = (entry.payload ?? {}) as Record<string, unknown>;
|
||||
switch (entry.eventType) {
|
||||
case 'remediation_approved': {
|
||||
const count =
|
||||
(Array.isArray(payload.actionIds) && payload.actionIds.length) ||
|
||||
(Array.isArray(payload.actions) && payload.actions.length) ||
|
||||
0;
|
||||
return {
|
||||
label: `${count} action(s) approved by ${entry.actor ?? 'unknown'}`,
|
||||
icon: CheckCircle2,
|
||||
dotClass: 'bg-blue-500',
|
||||
textClass: 'text-blue-600',
|
||||
};
|
||||
}
|
||||
case 'remediation_completed':
|
||||
return {
|
||||
label: 'Remediation completed',
|
||||
icon: ShieldCheck,
|
||||
dotClass: 'bg-green-500',
|
||||
textClass: 'text-green-600',
|
||||
};
|
||||
case 'campaign_marked_false_positive':
|
||||
return {
|
||||
label: 'Marked as false positive',
|
||||
icon: XCircle,
|
||||
dotClass: 'bg-slate-500',
|
||||
textClass: 'text-slate-600',
|
||||
};
|
||||
case 'campaign_classified': {
|
||||
const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | undefined;
|
||||
const tint = verdict ? VERDICT_TINT[verdict] : 'bg-muted-foreground text-muted-foreground';
|
||||
return {
|
||||
label: verdict ? `Classified as ${verdict}` : 'Campaign classified',
|
||||
icon: Sparkles,
|
||||
dotClass: tint.split(' ')[0],
|
||||
textClass: tint.split(' ')[1],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: humanizeEventType(entry.eventType),
|
||||
icon: Circle,
|
||||
dotClass: 'bg-muted-foreground',
|
||||
textClass: 'text-muted-foreground',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function TimelineCard({ timeline }: TimelineCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-bold">Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No timeline events yet.</p>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{timeline.map((entry, idx) => {
|
||||
const { label, icon: Icon, dotClass, textClass } = renderEntry(entry);
|
||||
const isLast = idx === timeline.length - 1;
|
||||
const actor = entry.kind === 'audit' ? entry.actor : null;
|
||||
const absolute = new Date(entry.at).toLocaleString();
|
||||
const relative = formatDistanceToNow(new Date(entry.at), { addSuffix: true });
|
||||
return (
|
||||
<div key={idx} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn('h-2 w-2 rounded-full shrink-0 mt-1.5', dotClass)}
|
||||
/>
|
||||
{!isLast && <span className="border-l flex-1 w-0 min-h-4" />}
|
||||
</div>
|
||||
<div className="pb-4 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Icon className={cn('h-3.5 w-3.5 shrink-0', textClass)} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground" title={absolute}>
|
||||
{relative}
|
||||
</span>
|
||||
{actor && (
|
||||
<span className="text-xs text-muted-foreground">{actor}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue