From 8858478e2227584e52c5e07284b8d0d3fbb08f87 Mon Sep 17 00:00:00 2001 From: Kent Bull Date: Tue, 21 Apr 2026 18:57:25 -0600 Subject: [PATCH] feat: timestamps on notes and ops Signed-off-by: Kent Bull --- src/app/TopBar.tsx | 48 +++++++- src/app/runtime.ts | 27 ++++- src/app/timeFormat.ts | 36 ++++++ .../identifiers/IdentifierDetailsModal.tsx | 36 +++++- src/features/identifiers/IdentifiersView.tsx | 58 +++++++++- .../notifications/AppNotificationsView.tsx | 106 +++++++++++++++++- .../operations/OperationDetailView.tsx | 11 +- src/features/operations/OperationsView.tsx | 23 +++- src/state/identifiers.slice.ts | 23 +++- src/state/selectors.ts | 31 +++-- src/workflows/identifiers.op.ts | 24 ++++ 11 files changed, 391 insertions(+), 32 deletions(-) create mode 100644 src/app/timeFormat.ts diff --git a/src/app/TopBar.tsx b/src/app/TopBar.tsx index 71c4b191..66dda477 100644 --- a/src/app/TopBar.tsx +++ b/src/app/TopBar.tsx @@ -19,6 +19,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import NotificationsIcon from '@mui/icons-material/Notifications'; import { Link as RouterLink } from 'react-router-dom'; import { StatusPill } from './Console'; +import { formatOperationWindow, formatTimestamp } from './timeFormat'; import type { AppNotificationRecord } from '../state/appNotifications.slice'; import type { OperationRecord } from '../state/operations.slice'; import { allAppNotificationsRead } from '../state/appNotifications.slice'; @@ -238,7 +239,28 @@ export const TopBar = ({ > + + {operation.phase} + + {formatOperationWindow( + operation + ) !== null && ( + + {formatOperationWindow( + operation + )} + + )} + + } /> )) @@ -293,7 +315,29 @@ export const TopBar = ({ > + {formatTimestamp( + notification.createdAt + ) !== null && ( + + Created{' '} + {formatTimestamp( + notification.createdAt + )} + + )} + + {notification.message} + + + } /> )) diff --git a/src/app/runtime.ts b/src/app/runtime.ts index 4394a255..f2f122f3 100644 --- a/src/app/runtime.ts +++ b/src/app/runtime.ts @@ -40,6 +40,7 @@ import { sessionDisconnected } from '../state/session.slice'; import { appStore, type AppStore } from '../state/store'; import { createIdentifierOp, + getIdentifierOp, listIdentifiersOp, rotateIdentifierOp, } from '../workflows/identifiers.op'; @@ -198,7 +199,8 @@ const abortError = (signal?: AbortSignal): Error => { return error; }; -const operationRoute = (requestId: string): string => `/operations/${requestId}`; +const operationRoute = (requestId: string): string => + `/operations/${requestId}`; const notificationId = (requestId: string): string => `notification-${requestId}-${Date.now()}`; @@ -419,6 +421,20 @@ export class AppRuntime { kind: options.kind ?? 'listIdentifiers', }); + /** + * Fetch one identifier by alias or prefix and merge richer state into Redux. + */ + getIdentifier = async ( + aid: string, + options: WorkflowRunOptions = {} + ): Promise => + this.runWorkflow(() => getIdentifierOp(aid), { + ...options, + label: options.label, + kind: options.kind ?? 'listIdentifiers', + track: options.track ?? false, + }); + /** * Create an identifier, wait for the resulting KERIA operation, then return * a freshly loaded identifier list for router revalidation callers. @@ -458,7 +474,8 @@ export class AppRuntime { requestId: options.requestId, label: `Creating identifier ${name}`, title: `Create identifier ${name}`, - description: 'Creates a managed identifier and waits for KERIA completion.', + description: + 'Creates a managed identifier and waits for KERIA completion.', kind: 'createIdentifier', resourceKeys: [`identifier:name:${name}`], resultRoute: { label: 'View identifiers', path: '/identifiers' }, @@ -483,7 +500,8 @@ export class AppRuntime { requestId: options.requestId, label: `Rotating identifier ${aid}`, title: `Rotate identifier ${aid}`, - description: 'Rotates a managed identifier and waits for KERIA completion.', + description: + 'Rotates a managed identifier and waits for KERIA completion.', kind: 'rotateIdentifier', resourceKeys: [`identifier:aid:${aid}`], resultRoute: { label: 'View identifiers', path: '/identifiers' }, @@ -707,7 +725,8 @@ export class AppRuntime { const notification: AppNotificationRecord = { id, severity: - template.severity ?? (outcome === 'success' ? 'success' : 'error'), + template.severity ?? + (outcome === 'success' ? 'success' : 'error'), status: 'unread', title: template.title, message: diff --git a/src/app/timeFormat.ts b/src/app/timeFormat.ts new file mode 100644 index 00000000..1837cfdc --- /dev/null +++ b/src/app/timeFormat.ts @@ -0,0 +1,36 @@ +export const formatTimestamp = ( + value: string | null | undefined +): string | null => { + if (value === null || value === undefined || value.trim().length === 0) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'medium', + }).format(date); +}; + +export const formatOperationWindow = ({ + startedAt, + finishedAt, +}: { + startedAt: string | null | undefined; + finishedAt?: string | null; +}): string | null => { + const started = formatTimestamp(startedAt); + const finished = formatTimestamp(finishedAt); + + if (started === null) { + return null; + } + + return finished === null + ? `Started ${started}` + : `Started ${started} | Ended ${finished}`; +}; diff --git a/src/features/identifiers/IdentifierDetailsModal.tsx b/src/features/identifiers/IdentifierDetailsModal.tsx index 4a9b859c..7b7b5acd 100644 --- a/src/features/identifiers/IdentifierDetailsModal.tsx +++ b/src/features/identifiers/IdentifierDetailsModal.tsx @@ -34,6 +34,8 @@ import { export interface IdentifierDetailsModalProps { open: boolean; identifier: IdentifierSummary | null; + refreshStatus: 'idle' | 'loading' | 'success' | 'error'; + refreshMessage: string | null; actionRunning: boolean; onClose: () => void; onRotate: (name: string) => void; @@ -205,15 +207,22 @@ const JsonCodeBlock = ({ value }: { value: string }) => ( export const IdentifierDetailsModal = ({ open, identifier, + refreshStatus, + refreshMessage, actionRunning, onClose, onRotate, }: IdentifierDetailsModalProps) => { - const currentKeys = identifier === null ? [] : identifierCurrentKeys(identifier); + const currentKeys = + identifier === null ? [] : identifierCurrentKeys(identifier); const currentKey = identifier === null ? identifierUnavailableValue : (identifierCurrentKey(identifier) ?? identifierUnavailableValue); + const currentKeyDisplay = + refreshStatus === 'loading' && currentKey === identifierUnavailableValue + ? 'Loading from KERIA...' + : currentKey; const additionalKeyCount = Math.max(currentKeys.length - 1, 0); return ( @@ -252,7 +261,9 @@ export const IdentifierDetailsModal = ({ > @@ -276,12 +287,19 @@ export const IdentifierDetailsModal = ({ /> 0 ? ( + refreshStatus === 'loading' ? ( + + ) : additionalKeyCount > 0 ? ( + {refreshStatus === 'error' && refreshMessage !== null && ( + + Unable to refresh identifier details:{' '} + {refreshMessage} + + )} {identifier !== null && ( }> Advanced JSON - + )} diff --git a/src/features/identifiers/IdentifiersView.tsx b/src/features/identifiers/IdentifiersView.tsx index c3d9d9c8..0a708a2a 100644 --- a/src/features/identifiers/IdentifiersView.tsx +++ b/src/features/identifiers/IdentifiersView.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, Button, Fab, Typography } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import { useFetcher, useLoaderData } from 'react-router-dom'; import { ConnectionRequired } from '../../app/ConnectionRequired'; import { EmptyState, PageHeader, StatusPill } from '../../app/Console'; +import { useAppRuntime } from '../../app/runtimeHooks'; import type { IdentifierActionData, IdentifiersLoaderData, @@ -15,6 +16,7 @@ import { idleIdentifierAction, type IdentifierActionState, type IdentifierCreateDraft, + type IdentifierSummary, } from './identifierTypes'; import { useAppSelector } from '../../state/hooks'; import { @@ -32,6 +34,7 @@ import { export const IdentifiersView = () => { const loaderData = useLoaderData() as IdentifiersLoaderData; const fetcher = useFetcher(); + const runtime = useAppRuntime(); const [selectedIdentifierName, setSelectedIdentifierName] = useState< string | null >(null); @@ -40,6 +43,10 @@ export const IdentifiersView = () => { string | null >(null); const [pendingMessage, setPendingMessage] = useState(null); + const [detailRefresh, setDetailRefresh] = useState<{ + status: 'idle' | 'loading' | 'success' | 'error'; + message: string | null; + }>({ status: 'idle', message: null }); const actionRunning = fetcher.state !== 'idle'; const liveIdentifiers = useAppSelector(selectIdentifiers); const activeOperations = useAppSelector(selectActiveOperations); @@ -65,6 +72,40 @@ export const IdentifiersView = () => { (identifier) => identifier.name === selectedIdentifierName ) ?? null); + useEffect(() => { + if (selectedIdentifierName === null) { + return undefined; + } + + const controller = new AbortController(); + + void runtime + .getIdentifier(selectedIdentifierName, { + signal: controller.signal, + track: false, + }) + .then(() => { + if (!controller.signal.aborted) { + setDetailRefresh({ status: 'success', message: null }); + } + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + + setDetailRefresh({ + status: 'error', + message: + error instanceof Error ? error.message : String(error), + }); + }); + + return () => { + controller.abort(); + }; + }, [runtime, selectedIdentifierName]); + if (loaderData.status === 'blocked') { return ; } @@ -133,6 +174,10 @@ export const IdentifiersView = () => { setActiveCreateRequestId(null); setCreateOpen(true); }; + const handleSelectIdentifier = (identifier: IdentifierSummary) => { + setDetailRefresh({ status: 'loading', message: null }); + setSelectedIdentifierName(identifier.name); + }; return ( @@ -215,9 +260,7 @@ export const IdentifiersView = () => { )} - setSelectedIdentifierName(identifier.name) - } + onSelect={handleSelectIdentifier} onRotate={handleRotate} isRotateDisabled={(identifier) => isRotateDisabled(identifier.name) @@ -229,12 +272,17 @@ export const IdentifiersView = () => { selectedIdentifier !== null } identifier={selectedIdentifier} + refreshStatus={detailRefresh.status} + refreshMessage={detailRefresh.message} actionRunning={ selectedIdentifierName === null ? false : isRotateDisabled(selectedIdentifierName) } - onClose={() => setSelectedIdentifierName(null)} + onClose={() => { + setSelectedIdentifierName(null); + setDetailRefresh({ status: 'idle', message: null }); + }} onRotate={handleRotate} /> {createDialogOpen && ( diff --git a/src/features/notifications/AppNotificationsView.tsx b/src/features/notifications/AppNotificationsView.tsx index cd91e79d..0f5a3272 100644 --- a/src/features/notifications/AppNotificationsView.tsx +++ b/src/features/notifications/AppNotificationsView.tsx @@ -9,16 +9,26 @@ import { Typography, } from '@mui/material'; import { Link as RouterLink } from 'react-router-dom'; -import { EmptyState, PageHeader, StatusPill } from '../../app/Console'; +import { + ConsolePanel, + EmptyState, + PageHeader, + StatusPill, +} from '../../app/Console'; +import { formatTimestamp } from '../../app/timeFormat'; import { useAppDispatch, useAppSelector } from '../../state/hooks'; import { allAppNotificationsRead } from '../../state/appNotifications.slice'; -import { selectAppNotifications } from '../../state/selectors'; +import { + selectAppNotifications, + selectKeriaNotifications, +} from '../../state/selectors'; const APP_NOTIFICATION_READ_DELAY_MS = 1250; export const AppNotificationsView = () => { const dispatch = useAppDispatch(); const notifications = useAppSelector(selectAppNotifications); + const keriaNotifications = useAppSelector(selectKeriaNotifications); const unreadCount = notifications.filter( (notification) => notification.status === 'unread' ).length; @@ -97,6 +107,19 @@ export const AppNotificationsView = () => { } secondary={ + {formatTimestamp( + notification.createdAt + ) !== null && ( + + Created{' '} + {formatTimestamp( + notification.createdAt + )} + + )} { ))} )} + {keriaNotifications.length > 0 && ( + + + {keriaNotifications.map((notification) => ( + + + + {notification.route} + + + + } + secondary={ + + {formatTimestamp( + notification.updatedAt + ) !== null && ( + + Updated{' '} + {formatTimestamp( + notification.updatedAt + )} + + )} + {notification.message !== null && ( + + {notification.message} + + )} + + } + /> + + ))} + + + )} ); }; diff --git a/src/features/operations/OperationDetailView.tsx b/src/features/operations/OperationDetailView.tsx index 80c23f83..0096f21e 100644 --- a/src/features/operations/OperationDetailView.tsx +++ b/src/features/operations/OperationDetailView.tsx @@ -6,6 +6,7 @@ import { StatusPill, TelemetryRow, } from '../../app/Console'; +import { formatTimestamp } from '../../app/timeFormat'; import { useAppSelector } from '../../state/hooks'; import { selectOperationById } from '../../state/selectors'; @@ -73,8 +74,14 @@ export const OperationDetailView = () => { - - + + { /> } - secondary={`${operation.kind} | ${operation.phase}`} + secondary={ + + + {operation.kind} | {operation.phase} + + {formatOperationWindow(operation) !== + null && ( + + {formatOperationWindow( + operation + )} + + )} + + } /> ))} diff --git a/src/state/identifiers.slice.ts b/src/state/identifiers.slice.ts index 09a6c821..2a9653ec 100644 --- a/src/state/identifiers.slice.ts +++ b/src/state/identifiers.slice.ts @@ -51,6 +51,21 @@ export const identifiersSlice = createSlice({ ) { replaceIdentifiers(state, payload.identifiers, payload.loadedAt); }, + identifierLoaded( + state, + { + payload, + }: PayloadAction<{ + identifier: IdentifierSummary; + loadedAt: string; + }> + ) { + state.byPrefix[payload.identifier.prefix] = payload.identifier; + if (!state.prefixes.includes(payload.identifier.prefix)) { + state.prefixes.push(payload.identifier.prefix); + } + state.loadedAt = payload.loadedAt; + }, identifierCreated( state, { @@ -81,8 +96,12 @@ export const identifiersSlice = createSlice({ }); /** Action creators for identifier list and mutation results. */ -export const { identifierListLoaded, identifierCreated, identifierRotated } = - identifiersSlice.actions; +export const { + identifierListLoaded, + identifierLoaded, + identifierCreated, + identifierRotated, +} = identifiersSlice.actions; /** Reducer mounted at `state.identifiers`. */ export const identifiersReducer = identifiersSlice.reducer; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index fc0a4b77..c53562b0 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -1,12 +1,14 @@ import type { RootState } from './store'; import type { OperationRecord } from './operations.slice'; import type { AppNotificationRecord } from './appNotifications.slice'; +import type { NotificationRecord } from './notifications.slice'; /** Select the serializable session connection summary. */ export const selectSession = (state: RootState) => state.session; /** Select only the session status for shell rendering. */ -export const selectConnectionStatus = (state: RootState) => state.session.status; +export const selectConnectionStatus = (state: RootState) => + state.session.status; /** Select operation records in display order. */ export const selectOperationRecords = (state: RootState) => @@ -57,6 +59,11 @@ const byNewestTimestamp = ( right: AppNotificationRecord ): number => right.createdAt.localeCompare(left.createdAt); +const byNewestKeriaNotificationTimestamp = ( + left: NotificationRecord, + right: NotificationRecord +): number => right.updatedAt.localeCompare(left.updatedAt); + /** Select user-facing app notification records in descending timestamp order. */ export const selectAppNotifications = (state: RootState) => state.appNotifications.ids @@ -74,10 +81,8 @@ export const selectUnreadAppNotifications = (state: RootState) => ); /** Select one app notification by id. */ -export const selectAppNotificationById = - (id: string) => - (state: RootState) => - state.appNotifications.byId[id] ?? null; +export const selectAppNotificationById = (id: string) => (state: RootState) => + state.appNotifications.byId[id] ?? null; /** Build an alias lookup for resolved and pending contacts. */ export const selectContactsByAlias = (state: RootState) => { @@ -94,10 +99,8 @@ export const selectContactsByOobi = (state: RootState) => { }; /** Create a selector for one credential status by SAID. */ -export const selectCredentialStatus = - (said: string) => - (state: RootState) => - state.credentials.bySaid[said]?.status ?? null; +export const selectCredentialStatus = (said: string) => (state: RootState) => + state.credentials.bySaid[said]?.status ?? null; /** Select unread notifications for badge/count UI. */ export const selectUnreadNotifications = (state: RootState) => @@ -105,6 +108,16 @@ export const selectUnreadNotifications = (state: RootState) => .map((id) => state.notifications.byId[id]) .filter((notification) => notification?.status === 'unread'); +/** Select KERIA notification inventory records newest first. */ +export const selectKeriaNotifications = (state: RootState) => + state.notifications.ids + .map((id) => state.notifications.byId[id]) + .filter( + (notification): notification is NotificationRecord => + notification !== undefined + ) + .sort(byNewestKeriaNotificationTimestamp); + /** Select notifications that a polling/processing workflow may attempt. */ export const selectProcessableNotifications = (state: RootState) => state.notifications.ids diff --git a/src/workflows/identifiers.op.ts b/src/workflows/identifiers.op.ts index c96493e1..0bfb0ef0 100644 --- a/src/workflows/identifiers.op.ts +++ b/src/workflows/identifiers.op.ts @@ -2,6 +2,7 @@ import type { Operation as EffectionOperation } from 'effection'; import { AppServicesContext } from '../effects/contexts'; import { createIdentifierService, + getIdentifierService, listIdentifiersService, rotateIdentifierService, } from '../services/identifiers.service'; @@ -11,6 +12,7 @@ import type { } from '../features/identifiers/identifierTypes'; import { identifierCreated, + identifierLoaded, identifierListLoaded, identifierRotated, } from '../state/identifiers.slice'; @@ -39,6 +41,28 @@ export function* listIdentifiersOp(): EffectionOperation { return identifiers; } +/** + * Fetch one identifier by alias or prefix and merge the richer state into Redux. + */ +export function* getIdentifierOp( + aid: string +): EffectionOperation { + const services = yield* AppServicesContext.expect(); + const identifier = yield* getIdentifierService({ + client: services.runtime.requireConnectedClient(), + aid, + }); + + services.store.dispatch( + identifierLoaded({ + identifier, + loadedAt: new Date().toISOString(), + }) + ); + + return identifier; +} + /** * Create one identifier from a route draft, wait for KERIA completion, and * publish the refreshed identifier list.