← All proposals

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

proposals/LogsTableWithDivider/LogsTableWithDivider.stories.tsx232 lines · View on GitHub
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>
	),
};