mirror of
https://github.com/apotdevin/thunderhub.git
synced 2026-08-14 12:43:07 +02:00
chore: sidebar events
This commit is contained in:
parent
fd3387ae4e
commit
fdcd6dafaa
8 changed files with 608 additions and 154 deletions
|
|
@ -10,6 +10,7 @@ import { Toaster } from 'react-hot-toast';
|
|||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { useListener } from './hooks/UseListener';
|
||||
import { SseProvider } from './context/SseContext';
|
||||
import { EventLogProvider } from './context/EventLogContext';
|
||||
import { config } from './config/thunderhubConfig';
|
||||
import { LoadingCard } from './components/loading/LoadingCard';
|
||||
import { useGetNodeInfoQuery } from './graphql/queries/__generated__/getNodeInfo.generated';
|
||||
|
|
@ -162,11 +163,13 @@ export default function App() {
|
|||
<ConfigProvider initialConfig={{ theme: savedTheme }}>
|
||||
<SseProvider>
|
||||
<ContextProvider>
|
||||
<TooltipProvider>
|
||||
<Wrapper>
|
||||
<AuthenticatedRoutes />
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
<EventLogProvider>
|
||||
<TooltipProvider>
|
||||
<Wrapper>
|
||||
<AuthenticatedRoutes />
|
||||
</Wrapper>
|
||||
</TooltipProvider>
|
||||
</EventLogProvider>
|
||||
</ContextProvider>
|
||||
</SseProvider>
|
||||
</ConfigProvider>
|
||||
|
|
|
|||
123
src/client/src/context/EventLogContext.tsx
Normal file
123
src/client/src/context/EventLogContext.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
FC,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
export type EventField = { label: string; value: ReactNode };
|
||||
|
||||
export type EventLogEntry = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
summary: EventField[];
|
||||
details: EventField[];
|
||||
timestamp: number;
|
||||
status: 'success' | 'error';
|
||||
};
|
||||
|
||||
type State = {
|
||||
entries: EventLogEntry[];
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'add'; entry: Omit<EventLogEntry, 'id' | 'timestamp'> }
|
||||
| { type: 'dismiss'; id: string }
|
||||
| { type: 'clear' };
|
||||
|
||||
type Dispatch = (action: Action) => void;
|
||||
|
||||
const StateContext = createContext<State | undefined>(undefined);
|
||||
const DispatchContext = createContext<Dispatch | undefined>(undefined);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'add': {
|
||||
const entry: EventLogEntry = {
|
||||
...action.entry,
|
||||
id: String(++nextId),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return {
|
||||
entries: [entry, ...state.entries].slice(0, MAX_ENTRIES),
|
||||
};
|
||||
}
|
||||
case 'dismiss':
|
||||
return {
|
||||
entries: state.entries.filter(e => e.id !== action.id),
|
||||
};
|
||||
case 'clear':
|
||||
return { entries: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const EventLogProvider: FC<{ children?: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(reducer, { entries: [] });
|
||||
|
||||
return (
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
||||
</DispatchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useEventLogState = () => {
|
||||
const context = useContext(StateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useEventLogState must be used within an EventLogProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useEventLogDispatch = () => {
|
||||
const context = useContext(DispatchContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useEventLogDispatch must be used within an EventLogProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useEventLog = () => {
|
||||
const dispatch = useEventLogDispatch();
|
||||
|
||||
const addEvent = useCallback(
|
||||
(event: {
|
||||
title: string;
|
||||
summary: EventField[];
|
||||
details?: EventField[];
|
||||
status?: 'success' | 'error';
|
||||
type?: string;
|
||||
}) => {
|
||||
dispatch({
|
||||
type: 'add',
|
||||
entry: {
|
||||
title: event.title,
|
||||
summary: event.summary,
|
||||
details: event.details ?? [],
|
||||
status: event.status ?? 'success',
|
||||
type: event.type ?? 'event',
|
||||
},
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const clearEvents = useCallback(() => {
|
||||
dispatch({ type: 'clear' });
|
||||
}, [dispatch]);
|
||||
|
||||
return { addEvent, clearEvents };
|
||||
};
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import { useApolloClient } from '@apollo/client';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
getNodeLink,
|
||||
getTransactionLink,
|
||||
renderLine,
|
||||
} from '../components/generic/helpers';
|
||||
import { FC, useCallback, useEffect, useRef } from 'react';
|
||||
import { getNodeLink, getTransactionLink } from '../components/generic/helpers';
|
||||
import { useNotificationState } from '../context/NotificationContext';
|
||||
import { useEventLog, EventField } from '../context/EventLogContext';
|
||||
import { formatSats } from '../utils/helpers';
|
||||
import { useChannelInfo } from './UseChannelInfo';
|
||||
import { useNodeDetails } from './UseNodeDetails';
|
||||
|
|
@ -14,19 +10,6 @@ import { useSse, useSseEvent } from './useSse';
|
|||
|
||||
const refetchTimeMs = 1000 * 1;
|
||||
|
||||
const renderToast = (
|
||||
title: string,
|
||||
content: JSX.Element | null | string | number
|
||||
) => {
|
||||
return (
|
||||
<div>
|
||||
{title}
|
||||
<div className="h-1" />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerAlias: FC<{ pubkey: string }> = ({ pubkey }) => {
|
||||
const { alias } = useNodeDetails(pubkey);
|
||||
|
||||
|
|
@ -42,12 +25,10 @@ const ChannelPeerAlias: FC<{ id: string }> = ({ id }) => {
|
|||
};
|
||||
|
||||
export const useListener = (disabled?: boolean) => {
|
||||
const { channels, forwardAttempts, forwards, invoices, payments, autoClose } =
|
||||
const { channels, forwardAttempts, forwards, invoices, payments } =
|
||||
useNotificationState();
|
||||
|
||||
const options: { duration?: number } = useMemo(() => {
|
||||
return autoClose ? {} : { duration: Infinity };
|
||||
}, [autoClose]);
|
||||
const { addEvent } = useEventLog();
|
||||
|
||||
const refetchQueryTimeout: { current: ReturnType<typeof setTimeout> | null } =
|
||||
useRef(null);
|
||||
|
|
@ -87,33 +68,27 @@ export const useListener = (disabled?: boolean) => {
|
|||
message;
|
||||
|
||||
if (is_confirmed) {
|
||||
toast.success(
|
||||
renderToast(
|
||||
'Invoice Paid',
|
||||
<>
|
||||
{renderLine('Description', description)}
|
||||
{renderLine('Description Hash', description_hash)}
|
||||
{renderLine('Amount', formatSats(received))}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Invoice Paid',
|
||||
summary: [{ label: 'Amount', value: formatSats(received) }],
|
||||
details: [
|
||||
{ label: 'Description', value: description },
|
||||
{ label: 'Description Hash', value: description_hash },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
renderToast(
|
||||
'New Invoice Created',
|
||||
<>
|
||||
{renderLine('Description', description)}
|
||||
{renderLine('Description Hash', description_hash)}
|
||||
{renderLine('Amount', formatSats(tokens))}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'New Invoice Created',
|
||||
summary: [{ label: 'Amount', value: formatSats(tokens) }],
|
||||
details: [
|
||||
{ label: 'Description', value: description },
|
||||
{ label: 'Description Hash', value: description_hash },
|
||||
],
|
||||
});
|
||||
}
|
||||
handleRefetchQueries(['GetInvoices']);
|
||||
},
|
||||
[handleRefetchQueries, invoices, options]
|
||||
[handleRefetchQueries, invoices, addEvent]
|
||||
);
|
||||
|
||||
const handlePayment = useCallback(
|
||||
|
|
@ -122,25 +97,25 @@ export const useListener = (disabled?: boolean) => {
|
|||
|
||||
const { hops, fee, destination, tokens } = message;
|
||||
|
||||
const hopLines = hops.map((h: any, index: number) =>
|
||||
renderLine(`Hop ${index + 1}`, h.channel)
|
||||
);
|
||||
const hopFields: EventField[] = hops.map((h: any, index: number) => ({
|
||||
label: `Hop ${index + 1}`,
|
||||
value: h.channel,
|
||||
}));
|
||||
|
||||
toast.success(
|
||||
renderToast(
|
||||
'New Payment',
|
||||
<>
|
||||
{renderLine('Destination', <PeerAlias pubkey={destination} />)}
|
||||
{renderLine('Amount', formatSats(tokens))}
|
||||
{renderLine('Fee', fee ? formatSats(fee) : null)}
|
||||
{hopLines}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'New Payment',
|
||||
summary: [
|
||||
{ label: 'Amount', value: formatSats(tokens) },
|
||||
{ label: 'Destination', value: <PeerAlias pubkey={destination} /> },
|
||||
],
|
||||
details: [
|
||||
...(fee ? [{ label: 'Fee', value: formatSats(fee) }] : []),
|
||||
...hopFields,
|
||||
],
|
||||
});
|
||||
handleRefetchQueries(['GetPayments']);
|
||||
},
|
||||
[handleRefetchQueries, payments, options]
|
||||
[handleRefetchQueries, payments, addEvent]
|
||||
);
|
||||
|
||||
const handleForward = useCallback(
|
||||
|
|
@ -158,40 +133,35 @@ export const useListener = (disabled?: boolean) => {
|
|||
if (is_send || is_receive) return;
|
||||
|
||||
if (!is_confirmed && forwardAttempts) {
|
||||
toast.error(
|
||||
renderToast(
|
||||
'Forward Attempt',
|
||||
<>
|
||||
{renderLine('In Peer', <ChannelPeerAlias id={in_channel} />)}
|
||||
{renderLine('Out Peer', <ChannelPeerAlias id={out_channel} />)}
|
||||
{renderLine('In Channel', in_channel)}
|
||||
{renderLine('Out Channel', out_channel)}
|
||||
{renderLine('Tokens', formatSats(tokens))}
|
||||
{renderLine('Fee', fee ? formatSats(fee) : null)}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Forward Attempt',
|
||||
summary: [{ label: 'Tokens', value: formatSats(tokens) }],
|
||||
details: [
|
||||
{ label: 'In Peer', value: <ChannelPeerAlias id={in_channel} /> },
|
||||
{ label: 'Out Peer', value: <ChannelPeerAlias id={out_channel} /> },
|
||||
{ label: 'In Channel', value: in_channel },
|
||||
{ label: 'Out Channel', value: out_channel },
|
||||
...(fee ? [{ label: 'Fee', value: formatSats(fee) }] : []),
|
||||
],
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
if (is_confirmed && forwards) {
|
||||
toast.success(
|
||||
renderToast(
|
||||
'Successful Forward',
|
||||
<>
|
||||
{renderLine('In Peer', <ChannelPeerAlias id={in_channel} />)}
|
||||
{renderLine('Out Peer', <ChannelPeerAlias id={out_channel} />)}
|
||||
{renderLine('In Channel', in_channel)}
|
||||
{renderLine('Out Channel', out_channel)}
|
||||
{renderLine('Fee', fee ? formatSats(fee) : null)}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Successful Forward',
|
||||
summary: fee ? [{ label: 'Fee', value: formatSats(fee) }] : [],
|
||||
details: [
|
||||
{ label: 'In Peer', value: <ChannelPeerAlias id={in_channel} /> },
|
||||
{ label: 'Out Peer', value: <ChannelPeerAlias id={out_channel} /> },
|
||||
{ label: 'In Channel', value: in_channel },
|
||||
{ label: 'Out Channel', value: out_channel },
|
||||
],
|
||||
});
|
||||
handleRefetchQueries(['GetForwards']);
|
||||
}
|
||||
},
|
||||
[handleRefetchQueries, forwardAttempts, forwards, options]
|
||||
[handleRefetchQueries, forwardAttempts, forwards, addEvent]
|
||||
);
|
||||
|
||||
const handleClosed = useCallback(
|
||||
|
|
@ -233,28 +203,31 @@ export const useListener = (disabled?: boolean) => {
|
|||
return types.join(', ');
|
||||
};
|
||||
|
||||
toast.success(
|
||||
renderToast(
|
||||
'Channel Closed',
|
||||
<>
|
||||
{renderLine('Reason', getCloseType())}
|
||||
{renderLine('Capacity', formatSats(capacity))}
|
||||
{renderLine('Id', id)}
|
||||
{renderLine('Peer', <PeerAlias pubkey={partner_public_key} />)}
|
||||
{renderLine(
|
||||
'Tx',
|
||||
transaction_id ? getTransactionLink(transaction_id) : null
|
||||
)}
|
||||
{renderLine(
|
||||
'Closing Tx',
|
||||
close_transaction_id
|
||||
? getTransactionLink(close_transaction_id)
|
||||
: null
|
||||
)}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Channel Closed',
|
||||
summary: [
|
||||
{ label: 'Reason', value: getCloseType() },
|
||||
{ label: 'Capacity', value: formatSats(capacity) },
|
||||
],
|
||||
details: [
|
||||
{ label: 'Id', value: id },
|
||||
{
|
||||
label: 'Peer',
|
||||
value: <PeerAlias pubkey={partner_public_key} />,
|
||||
},
|
||||
...(transaction_id
|
||||
? [{ label: 'Tx', value: getTransactionLink(transaction_id) }]
|
||||
: []),
|
||||
...(close_transaction_id
|
||||
? [
|
||||
{
|
||||
label: 'Closing Tx',
|
||||
value: getTransactionLink(close_transaction_id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
handleRefetchQueries([
|
||||
'GetChannels',
|
||||
|
|
@ -262,7 +235,7 @@ export const useListener = (disabled?: boolean) => {
|
|||
'GetClosedChannels',
|
||||
]);
|
||||
},
|
||||
[handleRefetchQueries, channels, options]
|
||||
[handleRefetchQueries, channels, addEvent]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
|
|
@ -279,43 +252,47 @@ export const useListener = (disabled?: boolean) => {
|
|||
is_private,
|
||||
} = message;
|
||||
|
||||
toast.success(
|
||||
renderToast(
|
||||
'Channel Opened',
|
||||
<>
|
||||
{renderLine(
|
||||
'Initiated By',
|
||||
is_partner_initiated ? 'Your Peer' : 'You'
|
||||
)}
|
||||
{renderLine('Id', id)}
|
||||
{renderLine('Peer', <PeerAlias pubkey={partner_public_key} />)}
|
||||
{renderLine('Private', is_private ? 'Yes' : 'No')}
|
||||
{renderLine('Capacity', formatSats(capacity))}
|
||||
{renderLine('Local', formatSats(local_balance))}
|
||||
{renderLine('Remote', formatSats(remote_balance))}
|
||||
</>
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Channel Opened',
|
||||
summary: [
|
||||
{ label: 'Capacity', value: formatSats(capacity) },
|
||||
{
|
||||
label: 'Initiated By',
|
||||
value: is_partner_initiated ? 'Your Peer' : 'You',
|
||||
},
|
||||
],
|
||||
details: [
|
||||
{ label: 'Id', value: id },
|
||||
{
|
||||
label: 'Peer',
|
||||
value: <PeerAlias pubkey={partner_public_key} />,
|
||||
},
|
||||
{ label: 'Private', value: is_private ? 'Yes' : 'No' },
|
||||
{ label: 'Local', value: formatSats(local_balance) },
|
||||
{ label: 'Remote', value: formatSats(remote_balance) },
|
||||
],
|
||||
});
|
||||
handleRefetchQueries(['GetChannels', 'GetPendingChannels']);
|
||||
},
|
||||
[handleRefetchQueries, channels, options]
|
||||
[handleRefetchQueries, channels, addEvent]
|
||||
);
|
||||
|
||||
const handleOpening = useCallback(
|
||||
(message: any) => {
|
||||
if (!channels) return;
|
||||
|
||||
toast.success(
|
||||
renderToast(
|
||||
'Channel Opening',
|
||||
renderLine('Transaction', getTransactionLink(message.transaction_id))
|
||||
),
|
||||
options
|
||||
);
|
||||
addEvent({
|
||||
title: 'Channel Opening',
|
||||
summary: [
|
||||
{
|
||||
label: 'Transaction',
|
||||
value: getTransactionLink(message.transaction_id),
|
||||
},
|
||||
],
|
||||
});
|
||||
handleRefetchQueries(['GetChannels', 'GetPendingChannels']);
|
||||
},
|
||||
[handleRefetchQueries, channels, options]
|
||||
[handleRefetchQueries, channels, addEvent]
|
||||
);
|
||||
|
||||
useSseEvent('invoice_updated', handleInvoice);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const Footer = () => {
|
|||
|
||||
return (
|
||||
<div className="absolute bottom-0 w-full h-[120px]">
|
||||
<div className="dark w-full bg-background px-4">
|
||||
<div className="dark w-full bg-background px-4 border-t border-border/60">
|
||||
<div
|
||||
className={
|
||||
pathname === '/login'
|
||||
|
|
|
|||
|
|
@ -121,20 +121,20 @@ export const BalancesContent = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 mb-2">
|
||||
<div>
|
||||
<div className="p-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
Liquidity
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col pb-2">
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50"
|
||||
onClick={() => setOpenDialog('open')}
|
||||
>
|
||||
<Cable size={13} className="text-blue-500" />
|
||||
Open Channel
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50"
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/50"
|
||||
onClick={() => setOpenDialog('buy')}
|
||||
>
|
||||
<Rocket size={13} className="text-orange-500" />
|
||||
|
|
|
|||
271
src/client/src/layouts/sidebar/EventLog.tsx
Normal file
271
src/client/src/layouts/sidebar/EventLog.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { FC, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useEventLogState,
|
||||
useEventLogDispatch,
|
||||
useEventLog,
|
||||
EventLogEntry,
|
||||
EventField,
|
||||
} from '../../context/EventLogContext';
|
||||
import {
|
||||
useNotificationState,
|
||||
useNotificationDispatch,
|
||||
} from '../../context/NotificationContext';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '../../components/ui/popover';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import {
|
||||
Trash2,
|
||||
Zap,
|
||||
ArrowRightLeft,
|
||||
Cable,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { fakeEvents } from './fakeEvents';
|
||||
|
||||
const formatRelativeTime = (timestamp: number): string => {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return 'now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
};
|
||||
|
||||
const getIcon = (title: string) => {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('invoice'))
|
||||
return <FileText size={12} className="text-purple-400" />;
|
||||
if (t.includes('payment'))
|
||||
return <Zap size={12} className="text-yellow-500" />;
|
||||
if (t.includes('forward'))
|
||||
return <ArrowRightLeft size={12} className="text-blue-400" />;
|
||||
if (t.includes('channel'))
|
||||
return <Cable size={12} className="text-green-400" />;
|
||||
return <Zap size={12} className="text-yellow-500" />;
|
||||
};
|
||||
|
||||
const FieldRow: FC<{ field: EventField }> = ({ field }) => {
|
||||
if (!field.value) return null;
|
||||
return (
|
||||
<div className="flex justify-between gap-2 text-[11px] leading-snug">
|
||||
<span className="text-muted-foreground/50 shrink-0">{field.label}</span>
|
||||
<span className="text-right truncate">{field.value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventEntry: FC<{ entry: EventLogEntry; index: number }> = ({
|
||||
entry,
|
||||
index,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDetails = entry.details.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`px-2 ${index % 2 === 1 ? 'bg-muted/30' : ''}`}>
|
||||
<div className="flex items-start gap-2 py-1.5">
|
||||
<span
|
||||
className={`mt-0.5 shrink-0 ${
|
||||
entry.status === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-muted-foreground/70'
|
||||
}`}
|
||||
>
|
||||
{getIcon(entry.title)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-medium truncate text-foreground">
|
||||
{entry.title}
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground/40 shrink-0">
|
||||
{formatRelativeTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
{entry.summary.map((f, i) => (
|
||||
<FieldRow key={i} field={f} />
|
||||
))}
|
||||
</div>
|
||||
{hasDetails && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-0.5 mt-0.5 text-[10px] text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
size={10}
|
||||
className={`transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{expanded ? 'Less' : 'More'}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-0.5 text-muted-foreground [&_a]:text-blue-400 [&_a]:hover:underline">
|
||||
{entry.details.map((f, i) => (
|
||||
<FieldRow key={i} field={f} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationToggles: FC = () => {
|
||||
const { invoices, payments, channels, forwards, forwardAttempts } =
|
||||
useNotificationState();
|
||||
const dispatch = useNotificationDispatch();
|
||||
|
||||
const items = [
|
||||
{ label: 'Invoices', property: 'invoices', value: invoices },
|
||||
{ label: 'Payments', property: 'payments', value: payments },
|
||||
{ label: 'Channels', property: 'channels', value: channels },
|
||||
{ label: 'Forwards', property: 'forwards', value: forwards },
|
||||
{
|
||||
label: 'Forward Attempts',
|
||||
property: 'forwardAttempts',
|
||||
value: forwardAttempts,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">
|
||||
Enable event types
|
||||
</div>
|
||||
{items.map(item => (
|
||||
<div key={item.property} className="flex items-center justify-between">
|
||||
<span className="text-[11px]">{item.label}</span>
|
||||
<Switch
|
||||
checked={item.value}
|
||||
onCheckedChange={checked =>
|
||||
dispatch({ type: 'change', [item.property]: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventLog: FC = () => {
|
||||
const { entries } = useEventLogState();
|
||||
const dispatch = useEventLogDispatch();
|
||||
const { addEvent } = useEventLog();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [ping, setPing] = useState(false);
|
||||
const prevCount = useRef(entries.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length > prevCount.current) {
|
||||
setPing(true);
|
||||
const t = setTimeout(() => setPing(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prevCount.current = entries.length;
|
||||
}, [entries.length]);
|
||||
|
||||
useEffect(() => {
|
||||
prevCount.current = entries.length;
|
||||
}, [entries.length]);
|
||||
|
||||
const { invoices, payments, channels, forwards, forwardAttempts } =
|
||||
useNotificationState();
|
||||
const anyEnabled =
|
||||
invoices || payments || channels || forwards || forwardAttempts;
|
||||
|
||||
const pushFakeEvent = () => {
|
||||
const event = fakeEvents[Math.floor(Math.random() * fakeEvents.length)];
|
||||
addEvent(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between p-2 border-y border-border/60">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
Events
|
||||
{entries.length > 0 && (
|
||||
<span className="text-muted-foreground/40 flex items-center gap-2">
|
||||
{entries.length}
|
||||
{ping ? (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-purple-300 opacity-75" />
|
||||
<span className="absolute inline-flex size-2 rounded-full bg-green-500" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-accent" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
title="Notification settings"
|
||||
>
|
||||
<Settings size={11} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-52">
|
||||
<NotificationToggles />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{entries.length > 0 && (
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'clear' })}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={pushFakeEvent}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
title="Add fake event"
|
||||
>
|
||||
<Plus size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{!anyEnabled ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-1.5 text-muted-foreground/40">
|
||||
<Settings size={16} />
|
||||
<span className="text-[11px]">No events enabled</span>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-1.5 text-muted-foreground/40">
|
||||
<Zap size={16} />
|
||||
<span className="text-[11px]">No events yet</span>
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry, i) => (
|
||||
<EventEntry key={entry.id} entry={entry} index={i} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useConfigState } from '../../context/ConfigContext';
|
||||
import { BalancesContent } from './BalancesContent';
|
||||
import { EventLog } from './EventLog';
|
||||
|
||||
export const RightSidebar = () => {
|
||||
const { rightSidebar } = useConfigState();
|
||||
|
|
@ -8,10 +9,13 @@ export const RightSidebar = () => {
|
|||
|
||||
return (
|
||||
<div className="hidden lg:flex flex-col shrink-0 w-[320px] border-l border-border/60">
|
||||
<div className="sticky top-[77px]">
|
||||
<div className="w-[320px]">
|
||||
<div className="sticky top-[77px] flex flex-col max-h-[calc(100vh-77px)]">
|
||||
<div className="w-[320px] shrink-0">
|
||||
<BalancesContent />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-[320px] flex flex-col">
|
||||
<EventLog />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
76
src/client/src/layouts/sidebar/fakeEvents.tsx
Normal file
76
src/client/src/layouts/sidebar/fakeEvents.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { EventField } from '../../context/EventLogContext';
|
||||
|
||||
export const fakeEvents: {
|
||||
title: string;
|
||||
summary: EventField[];
|
||||
details?: EventField[];
|
||||
status?: 'success' | 'error';
|
||||
}[] = [
|
||||
{
|
||||
title: 'Invoice Paid',
|
||||
summary: [{ label: 'Amount', value: '50,000 sats' }],
|
||||
details: [
|
||||
{ label: 'Description', value: 'Monthly VPN subscription' },
|
||||
{ label: 'Description Hash', value: 'a1b2c3...d4e5f6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'New Payment',
|
||||
summary: [
|
||||
{ label: 'Amount', value: '125,000 sats' },
|
||||
{ label: 'Destination', value: 'ACINQ' },
|
||||
],
|
||||
details: [
|
||||
{ label: 'Fee', value: '12 sats' },
|
||||
{ label: 'Hop 1', value: '824333x2100x1' },
|
||||
{ label: 'Hop 2', value: '710421x1500x0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Successful Forward',
|
||||
summary: [{ label: 'Fee', value: '3 sats' }],
|
||||
details: [
|
||||
{ label: 'In Peer', value: 'WalletOfSatoshi' },
|
||||
{ label: 'Out Peer', value: 'River Financial' },
|
||||
{ label: 'In Channel', value: '810111x2300x1' },
|
||||
{ label: 'Out Channel', value: '799000x1800x0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Forward Attempt',
|
||||
summary: [{ label: 'Tokens', value: '2,000,000 sats' }],
|
||||
details: [
|
||||
{ label: 'In Peer', value: 'Kraken' },
|
||||
{ label: 'Out Peer', value: 'Bitfinex' },
|
||||
{ label: 'In Channel', value: '800100x500x2' },
|
||||
{ label: 'Out Channel', value: '800200x600x1' },
|
||||
{ label: 'Fee', value: '150 sats' },
|
||||
],
|
||||
status: 'error',
|
||||
},
|
||||
{
|
||||
title: 'Channel Closed',
|
||||
summary: [
|
||||
{ label: 'Reason', value: 'Cooperative' },
|
||||
{ label: 'Capacity', value: '5,000,000 sats' },
|
||||
],
|
||||
details: [
|
||||
{ label: 'Id', value: '750000x1200x0' },
|
||||
{ label: 'Peer', value: 'LNBig' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Channel Opened',
|
||||
summary: [
|
||||
{ label: 'Capacity', value: '10,000,000 sats' },
|
||||
{ label: 'Initiated By', value: 'Your Peer' },
|
||||
],
|
||||
details: [
|
||||
{ label: 'Id', value: '812000x900x1' },
|
||||
{ label: 'Peer', value: 'Voltage' },
|
||||
{ label: 'Private', value: 'No' },
|
||||
{ label: 'Local', value: '0 sats' },
|
||||
{ label: 'Remote', value: '10,000,000 sats' },
|
||||
],
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue