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