Skip to content

Commit 91ea5ec

Browse files
Stub out virtualised audit log page
1 parent 0c49f07 commit 91ea5ec

File tree

10 files changed

+569
-1
lines changed

10 files changed

+569
-1
lines changed

app/layouts/SiloLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Access16Icon,
1313
Folder16Icon,
1414
Images16Icon,
15+
Logs16Icon,
1516
Metrics16Icon,
1617
} from '@oxide/design-system/icons/react'
1718

@@ -37,6 +38,7 @@ export default function SiloLayout() {
3738
{ value: 'Images', path: pb.siloImages() },
3839
{ value: 'Utilization', path: pb.siloUtilization() },
3940
{ value: 'Silo Access', path: pb.siloAccess() },
41+
{ value: 'Audit Logs', path: pb.siloAuditLogs() },
4042
]
4143
// filter out the entry for the path we're currently on
4244
.filter((i) => i.path !== pathname)
@@ -70,6 +72,9 @@ export default function SiloLayout() {
7072
<NavLinkItem to={pb.siloAccess()}>
7173
<Access16Icon /> Silo Access
7274
</NavLinkItem>
75+
<NavLinkItem to={pb.siloAuditLogs()}>
76+
<Logs16Icon /> Audit Logs
77+
</NavLinkItem>
7378
</Sidebar.Nav>
7479
</Sidebar>
7580
<ContentPane />

app/layouts/helpers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function ContentPane() {
2828
>
2929
<div className="flex grow flex-col pb-8">
3030
<SkipLinkTarget />
31-
<main className="[&>*]:gutter">
31+
<main className="[&>*]:gutter h-full">
3232
<Outlet />
3333
</main>
3434
</div>

app/pages/SiloAuditLogsPage.tsx

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { getLocalTimeZone, now } from '@internationalized/date'
9+
import { useInfiniteQuery, useIsFetching } from '@tanstack/react-query'
10+
import { useVirtualizer } from '@tanstack/react-virtual'
11+
import cn from 'classnames'
12+
import { differenceInMilliseconds } from 'date-fns'
13+
import { memo, useCallback, useMemo, useRef, useState } from 'react'
14+
15+
import { api } from '@oxide/api'
16+
import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react'
17+
18+
import { DocsPopover } from '~/components/DocsPopover'
19+
import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker'
20+
import { useIntervalPicker } from '~/components/RefetchIntervalPicker'
21+
import { Badge } from '~/ui/lib/Badge'
22+
import { Button } from '~/ui/lib/Button'
23+
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
24+
import { Spinner } from '~/ui/lib/Spinner'
25+
import { toSyslogDateString, toSyslogTimeString } from '~/util/date'
26+
import { docLinks } from '~/util/links'
27+
28+
// silly faux highlighting
29+
// avoids unnecessary import of a library and all that overhead
30+
const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => {
31+
const Indent = ({ depth }: { depth: number }) => (
32+
<span className="inline-block" style={{ width: `${depth * 4 + 1}ch` }} />
33+
)
34+
35+
const Primitive = ({ value }: { value: null | boolean | number | string }) => (
36+
<span className="text-[var(--base-blue-600)]">
37+
{value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)}
38+
</span>
39+
)
40+
41+
const renderValue = (
42+
value: null | boolean | number | string | object,
43+
depth = 0
44+
): React.ReactNode => {
45+
if (
46+
value === null ||
47+
typeof value === 'boolean' ||
48+
typeof value === 'number' ||
49+
typeof value === 'string'
50+
) {
51+
return <Primitive value={value} />
52+
}
53+
54+
if (Array.isArray(value)) {
55+
if (value.length === 0) return <span className="text-quaternary">[]</span>
56+
57+
return (
58+
<>
59+
<span className="text-quaternary">[</span>
60+
{'\n'}
61+
{value.map((item, index) => (
62+
<span key={index}>
63+
<Indent depth={depth + 1} />
64+
{renderValue(item, depth + 1)}
65+
{index < value.length - 1 && <span className="text-quaternary">,</span>}
66+
{'\n'}
67+
</span>
68+
))}
69+
<Indent depth={depth} />
70+
<span className="text-quaternary">]</span>
71+
</>
72+
)
73+
}
74+
75+
if (typeof value === 'object') {
76+
const entries = Object.entries(value)
77+
if (entries.length === 0) return <span className="text-quaternary">{'{}'}</span>
78+
79+
return (
80+
<>
81+
<span className="text-quaternary">{'{'}</span>
82+
{'\n'}
83+
{entries.map(([key, val], index) => (
84+
<span key={key}>
85+
<Indent depth={depth + 1} />
86+
<span className="text-default">{key}</span>
87+
<span className="text-quaternary">: </span>
88+
{renderValue(val, depth + 1)}
89+
{index < entries.length - 1 && <span className="text-quaternary">,</span>}
90+
{'\n'}
91+
</span>
92+
))}
93+
<Indent depth={depth} />
94+
<span className="text-quaternary">{'}'}</span>
95+
</>
96+
)
97+
}
98+
99+
return String(value)
100+
}
101+
102+
try {
103+
const parsed = JSON.parse(jsonString)
104+
return <>{renderValue(parsed)}</>
105+
} catch {
106+
return <>{jsonString}</>
107+
}
108+
})
109+
110+
export const handle = { crumb: 'Audit Logs' }
111+
112+
export default function SiloAuditLogsPage() {
113+
const [expandedItem, setExpandedItem] = useState<string | null>(null)
114+
115+
// pass refetch interval to this to keep the date up to date
116+
const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } =
117+
useDateTimeRangePicker({
118+
initialPreset: 'lastHour',
119+
maxValue: now(getLocalTimeZone()),
120+
})
121+
122+
const { intervalPicker } = useIntervalPicker({
123+
enabled: preset !== 'custom',
124+
isLoading: useIsFetching({ queryKey: ['auditLogList'] }) > 0,
125+
// sliding the range forward is sufficient to trigger a refetch
126+
fn: () => onRangeChange(preset),
127+
})
128+
129+
const queryParams = {
130+
startTime,
131+
endTime,
132+
limit: 500,
133+
}
134+
135+
const {
136+
data,
137+
fetchNextPage,
138+
hasNextPage,
139+
isFetchingNextPage,
140+
isLoading,
141+
isPending,
142+
isFetching,
143+
error,
144+
} = useInfiniteQuery({
145+
queryKey: ['auditLogList', { query: queryParams }],
146+
queryFn: ({ pageParam }) =>
147+
api.methods
148+
.auditLogList({ query: { ...queryParams, pageToken: pageParam } })
149+
.then((result) => {
150+
if (result.type === 'success') return result.data
151+
throw result
152+
}),
153+
initialPageParam: undefined as string | undefined,
154+
getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
155+
placeholderData: (x) => x,
156+
})
157+
158+
const auditLogs = useMemo(() => {
159+
return data?.pages.flatMap((page) => page.items) || []
160+
}, [data])
161+
162+
const parentRef = useRef<HTMLDivElement>(null)
163+
164+
const EXPANDED_HEIGHT = 282
165+
166+
const rowVirtualizer = useVirtualizer({
167+
count: auditLogs.length,
168+
getScrollElement: () => document.querySelector('#scroll-container'),
169+
estimateSize: useCallback(
170+
(index) => {
171+
return expandedItem === index.toString() ? 36 + EXPANDED_HEIGHT : 36
172+
},
173+
[expandedItem, EXPANDED_HEIGHT]
174+
),
175+
overscan: 20,
176+
})
177+
178+
const handleToggle = useCallback(
179+
(index: string | null) => {
180+
setExpandedItem(index)
181+
rowVirtualizer.measure()
182+
},
183+
[rowVirtualizer]
184+
)
185+
186+
const LogTable = () => (
187+
<>
188+
<div
189+
className="relative w-full"
190+
style={{
191+
height: `${rowVirtualizer.getTotalSize()}px`,
192+
}}
193+
>
194+
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
195+
const log = auditLogs[virtualRow.index]
196+
const isExpanded = expandedItem === virtualRow.index.toString()
197+
const jsonString = JSON.stringify(log, null, 2)
198+
199+
return (
200+
<div
201+
key={virtualRow.index}
202+
className="absolute left-0 right-0 top-0 w-full"
203+
style={{
204+
height: `${virtualRow.size}px`,
205+
transform: `translateY(${virtualRow.start}px)`,
206+
}}
207+
>
208+
<div>
209+
<button
210+
className={cn(
211+
'grid h-9 w-full cursor-pointer items-center gap-8 px-[var(--content-gutter)] text-left text-sans-md border-secondary',
212+
isExpanded ? 'bg-raise' : 'hover:bg-raise',
213+
virtualRow.index !== 0 && 'border-t'
214+
)}
215+
style={{
216+
gridTemplateColumns: '7rem 4.25rem 180px 120px 120px 120px 300px 300px',
217+
}}
218+
onClick={() => {
219+
const newValue = isExpanded ? null : virtualRow.index.toString()
220+
handleToggle(newValue)
221+
}}
222+
type="button"
223+
>
224+
<div className="overflow-hidden whitespace-nowrap text-mono-sm">
225+
<span className="text-tertiary">
226+
{toSyslogDateString(log.timestamp)}
227+
</span>{' '}
228+
{toSyslogTimeString(log.timestamp)}
229+
</div>
230+
<div className="flex gap-1 overflow-hidden whitespace-nowrap">
231+
<span className="text-mono-sm text-tertiary">POST</span>
232+
<Badge>200</Badge>
233+
</div>
234+
<div>
235+
<Badge color="neutral" className="text-tertiary">
236+
{log.operationId.split('_').join(' ')}
237+
</Badge>
238+
</div>
239+
<div className="text-secondary">hannah.arendt</div>
240+
<div>
241+
{!!log.accessMethod && (
242+
<Badge color="neutral" className="text-tertiary">
243+
{log.accessMethod.split('_').join(' ')}
244+
</Badge>
245+
)}
246+
</div>
247+
<div className="text-secondary">maze-war</div>
248+
<div className="text-secondary">
249+
{differenceInMilliseconds(new Date(log.timeCompleted), log.timestamp)}
250+
ms
251+
</div>
252+
</button>
253+
{isExpanded && (
254+
<div className="border-t px-[var(--content-gutter)] py-3 border-secondary">
255+
<pre className="h-full overflow-auto border-l pl-4 text-mono-code border-secondary">
256+
<HighlightJSON jsonString={jsonString} />
257+
</pre>
258+
</div>
259+
)}
260+
</div>
261+
</div>
262+
)
263+
})}
264+
</div>
265+
<div className="flex justify-center border-t px-[var(--content-gutter)] py-4 border-secondary">
266+
{!hasNextPage && !isFetching && !isPending && auditLogs.length > 0 ? (
267+
<div className="text-mono-sm text-quaternary">
268+
No more logs to show within selected timeline
269+
</div>
270+
) : (
271+
<Button
272+
variant="ghost"
273+
onClick={() => fetchNextPage()}
274+
disabled={isFetchingNextPage}
275+
className="text-mono-sm text-quaternary"
276+
type="button"
277+
>
278+
<div className="flex items-center gap-2">
279+
{isFetchingNextPage && <Spinner variant="secondary" />} Load More
280+
</div>
281+
</Button>
282+
)}
283+
</div>
284+
</>
285+
)
286+
287+
// todo
288+
// might want to still render the items in case of error
289+
const ErrorState = () => {
290+
return <div>Error State</div>
291+
}
292+
293+
// todo
294+
const LoadingState = () => {
295+
return <div>Loading State</div>
296+
}
297+
298+
return (
299+
<>
300+
<PageHeader>
301+
<PageTitle icon={<Logs24Icon />}>Audit Logs</PageTitle>
302+
<DocsPopover
303+
heading="audit logs"
304+
icon={<Logs16Icon />}
305+
summary="Audit logs provide a record of all system activities, including user actions, API calls, and system events."
306+
links={[docLinks.auditLogs]}
307+
/>
308+
</PageHeader>
309+
310+
<div className="!mx-0 mb-3 mt-8 flex !w-full flex-wrap justify-between gap-3 border-b px-[var(--content-gutter)] pb-4 border-secondary">
311+
<div className="flex gap-2">{intervalPicker}</div>
312+
<div className="flex items-center gap-2">{dateTimeRangePicker}</div>
313+
</div>
314+
315+
<div
316+
className="sticky top-0 z-10 !mx-0 grid !w-full items-center gap-8 border-b px-[var(--content-gutter)] pb-2 pt-4 bg-default border-secondary"
317+
style={{
318+
gridTemplateColumns: '7rem 4.25rem 180px 120px 120px 120px 300px 300px',
319+
}}
320+
>
321+
{['Time', 'Status', 'Operation', 'Actor', 'Access Method', 'Silo', 'Duration'].map(
322+
(header) => (
323+
<div key={header} className="text-mono-sm text-tertiary">
324+
{header}
325+
</div>
326+
)
327+
)}
328+
</div>
329+
330+
<div className="!mx-0 flex h-full !w-full flex-col">
331+
<div className="w-full flex-1" ref={parentRef}>
332+
{error ? <ErrorState /> : !isLoading ? <LogTable /> : <LoadingState />}
333+
</div>
334+
</div>
335+
</>
336+
)
337+
}

app/routes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ export const routes = createRoutesFromElements(
252252
</Route>
253253

254254
<Route path="access" lazy={() => import('./pages/SiloAccessPage').then(convert)} />
255+
<Route
256+
path="audit-logs"
257+
lazy={() => import('./pages/SiloAuditLogsPage').then(convert)}
258+
/>
255259
</Route>
256260

257261
{/* PROJECT */}

0 commit comments

Comments
 (0)