This commit is contained in:
@@ -1,15 +1,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||
} from '@/components/ds';
|
||||
import { useUserActivity } from '@/lib/krowHooks';
|
||||
import { AdminPage, SectionTitle, Toolbar } from '@/components/admin/PageShell';
|
||||
import { FilterSelect } from '@/pages/admin/Positions';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { AdminPage } from '@/components/admin/PageShell';
|
||||
import { UiTreeRenderer } from '@/components/ui-tree/UiTreeRenderer';
|
||||
import { composePage } from '@/lib/ui/composition';
|
||||
import { useUiEditing } from '@/components/ui-tree/UiEditingProvider';
|
||||
import { UiEditor } from '@/components/ui-editor/UiEditor';
|
||||
import '@/components/ui-tree/nodeTypes';
|
||||
import '@/pages/admin/activity/nodes';
|
||||
|
||||
/**
|
||||
* Admin Activity — an audit centre.
|
||||
@@ -33,35 +30,9 @@ const SEVERITY = {
|
||||
apply_job: 'low',
|
||||
};
|
||||
|
||||
const SEVERITY_META = {
|
||||
high: { label: 'High', variant: 'destructive' },
|
||||
medium: { label: 'Medium', variant: 'warning' },
|
||||
low: { label: 'Low', variant: 'neutral' },
|
||||
};
|
||||
|
||||
/** Severity carried through to the timeline node, so the two views agree. */
|
||||
const TIMELINE_TONE = { high: 'destructive', medium: 'warning', low: 'neutral' };
|
||||
|
||||
/** How many events the timeline shows before the table takes over. */
|
||||
const TIMELINE_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* An icon per event type. Worth the table: on a chronology the glyph is what
|
||||
* makes a run of hires distinguishable from a run of logins at a glance, which is
|
||||
* the whole reason to show a timeline rather than another list of rows.
|
||||
*/
|
||||
const EVENT_ICON = {
|
||||
hire_candidate: UserCheck,
|
||||
assign_employee: Users,
|
||||
create_position: FilePlus2,
|
||||
screen_candidate: ScanSearch,
|
||||
start_interview: Mic,
|
||||
apply_job: Send,
|
||||
signup: UserPlus,
|
||||
login: LogIn,
|
||||
logout: LogOut,
|
||||
};
|
||||
|
||||
/** Stable pseudo-IP from the email, so the demo shows metadata without inventing
|
||||
* a value that changes on every render. */
|
||||
const ipFor = (email) => {
|
||||
@@ -143,162 +114,42 @@ export default function AdminActivity() {
|
||||
return [...groups.entries()];
|
||||
}, [recent]);
|
||||
|
||||
/**
|
||||
* What this page's sections read.
|
||||
*
|
||||
* Published once, as one bag. The renderer passes it down untouched and never
|
||||
* looks inside, which is what keeps it ignorant of what an Activity page
|
||||
* contains — and what lets the next page publish something entirely
|
||||
* different without the renderer changing.
|
||||
*/
|
||||
const context = useMemo(() => ({
|
||||
isLoading, events, users, actions, filtered, isFiltered, clearFilters,
|
||||
search, setSearch, user, setUser, action, setAction, severity, setSeverity, range, setRange,
|
||||
highRisk, last24h, recent, byDay,
|
||||
}), [
|
||||
isLoading, events, users, actions, filtered, isFiltered,
|
||||
search, user, action, severity, range, highRisk, last24h, recent, byDay,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Activity"
|
||||
subtitle="Monitor workforce operations, user actions and system events."
|
||||
>
|
||||
<SkillSurface page="activity" placement="after-header" />
|
||||
<MetricStrip
|
||||
columns={4}
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: 'Total events', value: events.length },
|
||||
{ label: 'Last 24 hours', value: last24h.length, tone: last24h.length ? 'default' : 'warning' },
|
||||
{ label: 'Privileged actions', value: highRisk.length, tone: 'warning', sub: 'hires and position changes' },
|
||||
{ label: 'Distinct users', value: users.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{highRisk.length > 0 && (
|
||||
<Surface variant="solid" radius="lg" padding="sm" elevation="xs" className="border-warning/30 bg-warning-muted">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<p className="text-body-sm text-ink-2">
|
||||
<span className="font-semibold text-ink-1">{highRisk.length} privileged actions</span> in this log —
|
||||
hires and position changes. These should always trace to a named person.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* The recent timeline. Deliberately above the log and deliberately not
|
||||
filtered: this answers "what just happened", which is a different
|
||||
question from the one the toolbar below exists to ask. Reading a
|
||||
chronology is also how an auditor starts — sequence first, then
|
||||
interrogate the specifics. */}
|
||||
<section aria-labelledby="timeline" className="space-y-3">
|
||||
<SectionTitle
|
||||
id="timeline"
|
||||
title="Operational timeline"
|
||||
meta={`Most recent ${recent.length} events`}
|
||||
/>
|
||||
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className="overflow-hidden">
|
||||
{recent.length ? (
|
||||
<div className="divide-y divide-border">
|
||||
{byDay.map(([day, dayEvents]) => (
|
||||
<div key={day} className="px-4 py-3.5">
|
||||
<p className="mb-3 text-[10px] font-semibold uppercase tracking-wide text-ink-4">
|
||||
{day}
|
||||
</p>
|
||||
<Timeline
|
||||
compact
|
||||
items={dayEvents.map((e) => ({
|
||||
id: e.id,
|
||||
icon: EVENT_ICON[e.event_type] || Activity,
|
||||
tone: TIMELINE_TONE[e.severity],
|
||||
title: e.event_type.replace(/_/g, ' '),
|
||||
description: e.details || undefined,
|
||||
meta: `${e.user_name || e.user_email}${e.account_type ? ` · ${e.account_type}` : ''}`,
|
||||
timestamp: new Date(e.created_date).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
}),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-body-sm text-ink-3">
|
||||
No activity has been logged yet.
|
||||
</p>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="audit" className="space-y-3">
|
||||
<SectionTitle id="audit" title="Audit log" meta={`${filtered.length} of ${events.length} events`} />
|
||||
|
||||
<Toolbar
|
||||
search={<SearchInput value={search} onChange={setSearch} placeholder="Search events" size="sm" />}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect value={user} onChange={setUser} label="User"
|
||||
options={[{ value: 'all', label: 'All users' }, ...users.map((u) => ({ value: u, label: u }))]} />
|
||||
<FilterSelect value={action} onChange={setAction} label="Action"
|
||||
options={[{ value: 'all', label: 'All actions' }, ...actions.map((a) => ({ value: a, label: a.replace(/_/g, ' ') }))]} />
|
||||
<FilterSelect value={severity} onChange={setSeverity} label="Severity" options={[
|
||||
{ value: 'all', label: 'All severities' }, { value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' },
|
||||
]} />
|
||||
<FilterSelect value={range} onChange={setRange} label="Date" options={[
|
||||
{ value: 'all', label: 'All time' }, { value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' },
|
||||
]} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
loading={isLoading}
|
||||
rows={filtered}
|
||||
pageSize={20}
|
||||
isFiltered={isFiltered}
|
||||
onClearFilters={clearFilters}
|
||||
caption="Platform audit log"
|
||||
rowClassName={(e) => (e.severity === 'high' ? 'bg-warning-muted/40' : undefined)}
|
||||
columns={[
|
||||
{
|
||||
key: 'user_name', header: 'User', sortable: true,
|
||||
cell: (e) => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={e.user_name || e.user_email} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-ink-1">{e.user_name || '—'}</p>
|
||||
<p className="truncate text-[10px] text-ink-4">{e.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'event_type', header: 'Action', sortable: true,
|
||||
cell: (e) => <span className="whitespace-nowrap">{e.event_type.replace(/_/g, ' ')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'details', header: 'Entity', hideBelow: 'md',
|
||||
cell: (e) => <span className="line-clamp-1 text-ink-3">{e.details || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account_type', header: 'Role', hideBelow: 'lg',
|
||||
cell: (e) => <Badge variant={e.account_type === 'employer' ? 'info' : 'neutral'} size="sm">{e.account_type || 'unknown'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'ip', header: 'Source IP', hideBelow: 'lg',
|
||||
cell: (e) => <span className="font-mono text-[11px] text-ink-4">{e.ip}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created_date', header: 'Timestamp', align: 'right', sortable: true,
|
||||
sortValue: (e) => new Date(e.created_date).getTime(),
|
||||
cell: (e) => (
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-4">
|
||||
{new Date(e.created_date).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity', header: 'Severity', align: 'right',
|
||||
cell: (e) => (
|
||||
<Badge variant={SEVERITY_META[e.severity].variant} size="sm">
|
||||
{SEVERITY_META[e.severity].label}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<SkillSurface page="activity" placement="before-footer" />
|
||||
{/* The editing session is mounted by the layout, so the panel beside this
|
||||
page shares it. The page only renders what it is handed, which is why
|
||||
a preview in chat and the applied state cannot diverge. */}
|
||||
<UiEditor />
|
||||
<ActivityComposition context={context} />
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
/** The composed page. Split out so it can read the tree the provider computed. */
|
||||
function ActivityComposition({ context }) {
|
||||
const editing = useUiEditing();
|
||||
/* Outside a layout — a test, a storybook — there is no session, and the page
|
||||
still has to draw. It falls back to the composition as shipped. */
|
||||
const tree = editing?.tree || composePage('activity').tree;
|
||||
return <UiTreeRenderer nodes={tree} context={context} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user