import { ReactNode, SyntheticEvent } from 'react';
import { Table } from '@pantheon-systems/pds-toolkit-react';
import { Meta, StoryObj } from '@storybook/react-vite';
import { LabeledDivider } from '../LabeledDivider/LabeledDivider';
import './logs-table.css';
import './logs-table-divider.css';
type LogStatus = 'critical' | 'default' | 'warning';
interface LogEntry {
environment: string;
id: string;
ipAddress?: string;
level: 'ERROR' | 'INFO' | 'WARNING';
message: string;
method?: string;
path?: string;
source: string;
status: LogStatus;
statusCode?: number;
timestamp: string;
}
const sampleLogs: LogEntry[] = [
{
environment: 'dev',
id: '1',
ipAddress: '151.101.1.140',
level: 'ERROR',
message: '500 Internal Server Error: upstream connect error or disconnect',
method: 'GET',
path: '/api/users/profile',
source: 'Request',
status: 'critical',
statusCode: 500,
timestamp: 'Jun 29, 2026 17:23:45',
},
{
environment: 'dev',
id: '2',
level: 'WARNING',
message: 'High memory usage detected (87% of limit)',
source: 'System',
status: 'warning',
timestamp: 'Jun 29, 2026 17:23:12',
},
{
environment: 'dev',
id: '3',
level: 'INFO',
message: 'Deployment completed successfully: v2.14.1',
source: 'Deploy',
status: 'default',
timestamp: 'Jun 29, 2026 17:22:58',
},
{
environment: 'dev',
id: '4',
ipAddress: '10.0.0.1',
level: 'ERROR',
message: 'PHP Fatal error: Allowed memory size exhausted',
method: 'POST',
path: '/wp-cron.php',
source: 'Request',
status: 'critical',
statusCode: 500,
timestamp: 'Jun 29, 2026 17:22:31',
},
{
environment: 'dev',
id: '5',
level: 'INFO',
message: 'Cache cleared for all environments',
source: 'System',
status: 'default',
timestamp: 'Jun 29, 2026 17:21:50',
},
{
environment: 'dev',
id: '6',
ipAddress: '203.0.113.42',
level: 'WARNING',
message: 'Slow query detected: 4.2s (threshold: 1s)',
method: 'GET',
path: '/api/sites/list',
source: 'Request',
status: 'warning',
statusCode: 200,
timestamp: 'Jun 29, 2026 17:21:18',
},
{
environment: 'dev',
id: '7',
level: 'INFO',
message: 'Cron job wp-cron.php completed in 0.3s',
source: 'System',
status: 'default',
timestamp: 'Jun 29, 2026 17:20:44',
},
{
environment: 'dev',
id: '8',
level: 'INFO',
message: 'SSL certificate renewed: expires 2027-06-29',
source: 'System',
status: 'default',
timestamp: 'Jun 29, 2026 17:20:01',
},
];
// The divider is a row of its own. Its first cell holds the divider; the other
// cells are empty.
const DIVIDER_ID = 'divider-time-jump';
const toRowData = (log: LogEntry): Record<string, ReactNode> => ({ ...log });
const timeMenuItems = [
{ label: 'Jump to...', isHeading: true },
{ label: 'Today', callback: () => {} },
{ label: 'Yesterday', callback: () => {} },
{ label: 'Last week', callback: () => {} },
{ label: 'Last month', callback: () => {} },
{ label: 'The very beginning', callback: () => {} },
];
// The row is clickable in the Table (it opens the detail panel), so the
// divider's clicks and keys stay inside it. The menu and the date picker modal
// render in portals, but their events still reach this handler through the
// React tree.
const stopRowEvents = {
onClick: (e: SyntheticEvent) => e.stopPropagation(),
onKeyDown: (e: SyntheticEvent) => e.stopPropagation(),
};
const dividerRow: Record<string, ReactNode> = {
environment: '',
id: DIVIDER_ID,
level: '',
message: '',
status: 'divider',
timestamp: (
<LabeledDivider
{...stopRowEvents}
datePicker={{
maxDate: new Date(),
menuLabel: 'Jump to a specific date',
modalTitle: 'Jump to a date',
onChange: () => {},
pickerLabel: 'Date',
}}
id='logs-table-time-jump'
label='Jun 29, 2026'
menuItems={timeMenuItems}
size='xs'
/>
),
};
const columns = [
{ header: 'Time', id: 'timestamp' },
{ header: 'Level', id: 'level' },
{ header: 'Environment', id: 'environment' },
{ header: 'Message', id: 'message' },
];
// Newest first: the divider marks a day and lets the reader jump in time.
const rowData = [
...sampleLogs.slice(0, 5).map(toRowData),
dividerRow,
...sampleLogs.slice(5).map(toRowData),
];
const meta: Meta = {
tags: ['new-pattern', 'ready-for-dev'],
title: 'Recipes/Logs table with divider',
parameters: {
docs: {
description: {
component:
'**Recipe:** the PDS Logs Table pattern with a Labeled Divider as a row, as a time-jump marker. The table, status colors and detail panel are PDS; the divider row is a workaround, because Table has no row that spans its columns.',
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<div className='pds-logs-table'>
<Table
hasDesktopDetailPanel
hasPanel
aria-label='Application logs'
columns={columns}
detailPanelTitle='Log details'
getRowClassName={(row) => {
const status = String(row.status);
if (status === 'divider') return 'logs-table-example__divider-row';
if (status === 'critical') return 'pds-table__row--critical';
if (status === 'warning') return 'pds-table__row--warning';
return null;
}}
getRowId={(row) => String(row.id)}
maxHeight='30rem'
minHeight='30rem'
nextDetailPanelRowLabel='Next log entry'
prevDetailPanelRowLabel='Previous log entry'
renderDetailPanel={(row) => {
if (row.id === DIVIDER_ID) return null;
return (
<dl className='pds-logs-detail__section'>
<dt className='pds-logs-detail__label'>Timestamp</dt>
<dd className='pds-logs-detail__value'>{row.timestamp}</dd>
<dt className='pds-logs-detail__label'>Level</dt>
<dd className='pds-logs-detail__value'>{row.level}</dd>
<dt className='pds-logs-detail__label'>Source</dt>
<dd className='pds-logs-detail__value'>{row.source}</dd>
</dl>
);
}}
rowData={rowData}
/>
</div>
),
};
Logs table with divider
A recipe: the PDS Logs Table pattern (Table with hasPanel, status-colored rows and the inline detail panel) with a LabeledDivider as a row.
Extends a PDS componentOpen in Storybook