diff --git a/app/hooks/paginate.js b/app/hooks/paginate.js index e8b0539..cf8752c 100644 --- a/app/hooks/paginate.js +++ b/app/hooks/paginate.js @@ -1,7 +1,16 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export default function usePagination(length) { const [page, setPage] = useState({ start: 0, size: 10 }); + + // Keep the current page within bounds when the number of items shrinks. + useEffect(() => { + const maxStart = length <= 0 ? 0 : Math.floor((length - 1) / page.size) * page.size; + if (page.start > maxStart) { + setPage((current) => ({ ...current, start: maxStart })); + } + }, [length, page.start, page.size]); + const nextPage = useCallback( () => setPage((current) => ({ ...current, start: current.start + current.size })), [] diff --git a/app/routes/streams.$streamName.$from.$direction.$amount.jsx b/app/routes/streams.$streamName.$from.$direction.$amount.jsx index 3fd0cdf..25712fc 100644 --- a/app/routes/streams.$streamName.$from.$direction.$amount.jsx +++ b/app/routes/streams.$streamName.$from.$direction.$amount.jsx @@ -174,26 +174,26 @@ export default function EventStreamPaged() {
{prev <= 0 ? ( ) : ( - Prev + Newer )} {next <= 0 ? ( ) : ( - Next + Older )}
diff --git a/app/routes/streams.$streamName.jsx b/app/routes/streams.$streamName.jsx index d03a757..2232acf 100644 --- a/app/routes/streams.$streamName.jsx +++ b/app/routes/streams.$streamName.jsx @@ -13,9 +13,8 @@ export async function loader({ params, request }) { const storeNameOverride = url.searchParams.get('store') || undefined; const { eventstore } = await getEventStore({ readOnly: true }, storeNameOverride); - const from = 1; const amount = 10; - const direction = 'forwards'; + const direction = 'backwards'; const events = []; const streamIndex = eventstore.streams[streamName]?.index; const streamIndexMetadata = streamIndex?.metadata || null; @@ -31,11 +30,12 @@ export async function loader({ params, request }) { partitionMetadata = writePartition.metadata || null; } - const until = from + amount - 1; const streamLength = eventstore.getStreamVersion(streamName); + const from = streamLength; // start from the newest event + const until = Math.max(1, from - amount + 1); let stream = eventstore.getEventStream(streamName); if (stream !== false) { - stream = stream.from(from).forwards(amount); + stream = stream.from(from).backwards(amount); stream.forEach((payload, metadata, stream) => { events.push({ payload, metadata, stream }); }); @@ -46,8 +46,8 @@ export async function loader({ params, request }) { stream: events, direction, amount, - next: until >= streamLength ? 0 : until + 1, - prev: from - amount, + next: until <= 1 ? 0 : until - 1, // older events + prev: 0, // already at newest streamInfo: { indexMetadata: streamIndexMetadata, matcher, @@ -163,26 +163,26 @@ export default function EventStream() {
{prev <= 0 ? ( ) : ( - Prev + Newer )} {next <= 0 ? ( ) : ( - Next + Older )}
diff --git a/app/routes/streams._index.jsx b/app/routes/streams._index.jsx index 45ddce3..4e9ad48 100644 --- a/app/routes/streams._index.jsx +++ b/app/routes/streams._index.jsx @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { Link, useLoaderData } from 'react-router'; +import { useState } from 'react'; import getEventStore from '../../eventstore'; import DateFormat from '../components/date'; import Json from '../components/json'; @@ -14,7 +15,7 @@ export async function loader({ request }) { const streams = Object.keys(eventstore.streams).map((streamName) => { const stream = eventstore.streams[streamName].index; - const crtime = fs.statSync(stream.fileName).birthtimeMs; + const crtime = stream.crtime || fs.statSync(stream.fileName).birthtimeMs; return { name: streamName, length: stream.length, @@ -23,15 +24,215 @@ export async function loader({ request }) { }; }); + // Streams starting with '_' should always be shown first, then newest streams. + streams.sort((a, b) => { + const aIsSystem = a.name.startsWith('_'); + const bIsSystem = b.name.startsWith('_'); + if (aIsSystem !== bIsSystem) return aIsSystem ? -1 : 1; + return b.crtime - a.crtime; + }); + return { storeName: eventstore.storeName, streams }; } +/** Separates flat streams (no '/') from hierarchical ones and builds a recursive tree for the latter. */ +function buildStreamTree(streams) { + const createNode = () => ({ children: {}, stream: null }); + const root = createNode(); + const topLevelEntries = []; + + for (const stream of streams) { + if (!stream.name.includes('/')) { + topLevelEntries.push({ type: 'flat', stream }); + continue; + } + const parts = stream.name.split('/'); + const topLevelSegment = parts[0]; + + if (!root.children[topLevelSegment]) { + root.children[topLevelSegment] = createNode(); + topLevelEntries.push({ + type: 'group', + segment: topLevelSegment, + path: topLevelSegment, + node: root.children[topLevelSegment] + }); + } + + let node = root.children[topLevelSegment]; + for (const part of parts.slice(1, -1)) { + if (!node.children[part]) node.children[part] = createNode(); + node = node.children[part]; + } + const last = parts.at(-1); + if (!node.children[last]) node.children[last] = createNode(); + node.children[last].stream = stream; + } + + return { topLevelEntries, treeRoot: root }; +} + +function countLeaves(node) { + let count = node.stream ? 1 : 0; + for (const child of Object.values(node.children)) count += countLeaves(child); + return count; +} + +function buildVisibleRows(topLevelEntries, expandedPaths) { + const rows = []; + + const addGroupRow = (segment, node, depth, path) => { + const isOpen = expandedPaths.has(path); + rows.push({ + kind: 'group', + id: `group:${path}`, + path, + segment, + depth, + node, + isOpen, + leafCount: countLeaves(node), + counted: !isOpen + }); + + if (!isOpen) return; + + for (const [childSegment, childNode] of Object.entries(node.children)) { + const childPath = `${path}/${childSegment}`; + const hasChildren = Object.keys(childNode.children).length > 0; + if (hasChildren) { + addGroupRow(childSegment, childNode, depth + 1, childPath); + } else if (childNode.stream) { + rows.push({ + kind: 'leaf', + id: `leaf:${childNode.stream.name}`, + path: childPath, + segment: childSegment, + depth: depth + 1, + stream: childNode.stream, + counted: true + }); + } + } + }; + + for (const entry of topLevelEntries) { + if (entry.type === 'flat') { + rows.push({ + kind: 'leaf', + id: `leaf:${entry.stream.name}`, + path: entry.stream.name, + segment: entry.stream.name, + depth: 0, + stream: entry.stream, + counted: true + }); + continue; + } + + addGroupRow(entry.segment, entry.node, 0, entry.path); + } + + return rows; +} + +function buildPagedRows(rows, start, end) { + const selectedIds = new Set(); + const ancestorPaths = new Set(); + let countedIndex = 0; + + const collectAncestors = (path) => { + if (!path.includes('/')) return; + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + ancestorPaths.add(parts.slice(0, i).join('/')); + } + }; + + for (const row of rows) { + if (!row.counted) continue; + if (countedIndex >= start && countedIndex < end) { + selectedIds.add(row.id); + collectAncestors(row.path); + } + countedIndex += 1; + } + + return rows.filter((row) => selectedIds.has(row.id) || (row.kind === 'group' && ancestorPaths.has(row.path))); +} + +function renderRow(row, expandedPaths, onToggle) { + const indent = `calc(0.75rem + ${row.depth * 1.5}em)`; + + if (row.kind === 'group') { + return ( + onToggle(row.path)}> + +
+ + {expandedPaths.has(row.path) ? 'folder_open' : 'folder'} + {row.segment} + + + {row.leafCount} stream{row.leafCount !== 1 ? 's' : ''} + +
+ + + ); + } + + return ( + + + + receipt_long + {row.segment} + + + + + + + + + {row.stream.length} events + + + + + + ); +} + export default function StreamsIndex() { const { storeName, streams } = useLoaderData(); - const [start, end, nextPage, prevPage, hasNext, hasPrev] = usePagination(streams.length); + const { topLevelEntries, treeRoot } = buildStreamTree(streams); + const topLevelGroupCount = Object.keys(treeRoot.children).length; + const [expandedPaths, setExpandedPaths] = useState(() => new Set()); + + const togglePath = (path) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const rows = buildVisibleRows(topLevelEntries, expandedPaths); + const countedEntriesTotal = rows.reduce((count, row) => count + (row.counted ? 1 : 0), 0); + const [start, end, nextPage, prevPage, hasNext, hasPrev] = usePagination(countedEntriesTotal); + const visibleEntries = buildPagedRows(rows, start, end); + + const visibleStart = countedEntriesTotal === 0 ? 0 : Math.min(start + 1, countedEntriesTotal); + const visibleEnd = Math.min(end, countedEntriesTotal); return (
@@ -48,9 +249,15 @@ export default function StreamsIndex() { table_rows {streams.length} total streams + {topLevelGroupCount > 0 && ( + + account_tree + {topLevelGroupCount} top-level groups + + )} layers - {start + 1}-{Math.min(end, streams.length)} visible + {visibleStart}-{visibleEnd} paged items
@@ -74,41 +281,23 @@ export default function StreamsIndex() { Metadata - - {streams.slice(start, end).map((stream) => ( - - - {stream.name} - - - - - - - - {stream.length} events - - - - - - ))} - + + {visibleEntries.map((row) => renderRow(row, expandedPaths, togglePath))} +
- Showing {start + 1}-{Math.min(end, streams.length)} of {streams.length}{' '} - streams + Showing {visibleStart}-{visibleEnd} of {streams.length} streams
- - + +
diff --git a/public/assets/css/material-overrides.css b/public/assets/css/material-overrides.css index 63488be..13b9daa 100644 --- a/public/assets/css/material-overrides.css +++ b/public/assets/css/material-overrides.css @@ -846,4 +846,57 @@ .copyright > i { vertical-align: middle; +} + +/* ── Stream tree (hierarchical stream browser) ── */ + +.stream-cat-row { + cursor: pointer; + user-select: none; + background: color-mix(in srgb, var(--c-primary) 6%, var(--c-bkg-card)) !important; + transition: background 0.15s ease; +} + +.stream-cat-row:hover { + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-bkg-card)) !important; +} + +.stream-cat-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.stream-cat-toggle { + display: flex; + align-items: center; + gap: 8px; + color: var(--c-primary); +} + +.stream-cat-toggle .material-icons { + font-size: 20px; +} + +.stream-cat-label { + font-weight: 600; +} + +.stream-leaf-row td { + border-top: none !important; +} + +.stream-leaf-row:last-of-type td { + border-bottom: 1px solid var(--c-border); +} + +.stream-leaf-row .cell-name a { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stream-leaf-row i.material-icons { + font-size: 16px !important; } \ No newline at end of file