mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor: Make the audit log looks like a timeline (#4765)
This commit is contained in:
@@ -10,15 +10,19 @@ import {
|
||||
import { Pill } from "components/Pill/Pill"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar"
|
||||
import { ComponentProps, useState } from "react"
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
|
||||
import { useState } from "react"
|
||||
import { PaletteIndex } from "theme/palettes"
|
||||
import userAgentParser from "ua-parser-js"
|
||||
import { createDayString } from "util/createDayString"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
import { AuditLogDiff } from "./AuditLogDiff"
|
||||
|
||||
const pillTypeByHttpStatus = (
|
||||
httpStatus: number,
|
||||
): ComponentProps<typeof Pill>["type"] => {
|
||||
const readableActionMessage = (auditLog: AuditLog) => {
|
||||
return auditLog.description
|
||||
.replace("{user}", `<strong>${auditLog.user?.username.trim()}</strong>`)
|
||||
.replace("{target}", `<strong>${auditLog.resource_target.trim()}</strong>`)
|
||||
}
|
||||
|
||||
const httpStatusColor = (httpStatus: number): PaletteIndex => {
|
||||
if (httpStatus >= 300 && httpStatus < 500) {
|
||||
return "warning"
|
||||
}
|
||||
@@ -30,12 +34,6 @@ const pillTypeByHttpStatus = (
|
||||
return "success"
|
||||
}
|
||||
|
||||
const readableActionMessage = (auditLog: AuditLog) => {
|
||||
return auditLog.description
|
||||
.replace("{user}", `<strong>${auditLog.user?.username}</strong>`)
|
||||
.replace("{target}", `<strong>${auditLog.resource_target}</strong>`)
|
||||
}
|
||||
|
||||
export interface AuditLogRowProps {
|
||||
auditLog: AuditLog
|
||||
// Useful for Storybook
|
||||
@@ -63,13 +61,19 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={auditLog.id} data-testid={`audit-log-row-${auditLog.id}`}>
|
||||
<TableRow
|
||||
key={auditLog.id}
|
||||
data-testid={`audit-log-row-${auditLog.id}`}
|
||||
className={styles.auditLogRow}
|
||||
>
|
||||
<TableCell className={styles.auditLogCell}>
|
||||
<Stack
|
||||
style={{ cursor: shouldDisplayDiff ? "pointer" : undefined }}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className={styles.auditLogRow}
|
||||
className={combineClasses({
|
||||
[styles.auditLogHeader]: true,
|
||||
[styles.clickable]: shouldDisplayDiff,
|
||||
})}
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
@@ -81,51 +85,61 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
className={styles.auditLogRowInfo}
|
||||
className={styles.auditLogHeaderInfo}
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
<UserAvatar
|
||||
username={auditLog.user?.username ?? ""}
|
||||
avatarURL={auditLog.user?.avatar_url}
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
className={styles.auditLogResume}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readableActionMessage(auditLog),
|
||||
}}
|
||||
/>
|
||||
<span className={styles.auditLogTime}>
|
||||
{createDayString(auditLog.time)}
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="flex-end"
|
||||
spacing={1}
|
||||
className={styles.auditLogRight}
|
||||
>
|
||||
<Pill
|
||||
type={pillTypeByHttpStatus(auditLog.status_code)}
|
||||
text={auditLog.status_code.toString()}
|
||||
/>
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
className={styles.fullWidth}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className={styles.auditLogExtraInfo}
|
||||
>
|
||||
<div>
|
||||
<strong>IP</strong> {auditLog.ip ?? notAvailableLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>OS</strong> {os.name ?? notAvailableLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Browser</strong> {displayBrowserInfo}
|
||||
</div>
|
||||
<Stack
|
||||
className={styles.auditLogSummary}
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
spacing={1}
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readableActionMessage(auditLog),
|
||||
}}
|
||||
/>
|
||||
<span className={styles.auditLogTime}>
|
||||
{new Date(auditLog.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
<span className={styles.auditLogInfo}>
|
||||
IP: <strong>{auditLog.ip ?? notAvailableLabel}</strong>
|
||||
</span>
|
||||
|
||||
<span className={styles.auditLogInfo}>
|
||||
OS: <strong>{os.name ?? notAvailableLabel}</strong>
|
||||
</span>
|
||||
|
||||
<span className={styles.auditLogInfo}>
|
||||
Browser: <strong>{displayBrowserInfo}</strong>
|
||||
</span>
|
||||
</Stack>
|
||||
|
||||
<Pill
|
||||
className={styles.httpStatusPill}
|
||||
type={httpStatusColor(auditLog.status_code)}
|
||||
text={auditLog.status_code.toString()}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -150,27 +164,58 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
auditLogCell: {
|
||||
padding: "0 !important",
|
||||
border: 0,
|
||||
},
|
||||
|
||||
auditLogRow: {
|
||||
position: "relative",
|
||||
|
||||
"&:focus": {
|
||||
outlineStyle: "solid",
|
||||
outlineOffset: -1,
|
||||
outlineWidth: 2,
|
||||
outlineColor: theme.palette.secondary.dark,
|
||||
},
|
||||
|
||||
"&:not(:last-child) td:before": {
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
left: 50,
|
||||
display: "block",
|
||||
content: "''",
|
||||
height: "100%",
|
||||
width: 2,
|
||||
background: theme.palette.divider,
|
||||
},
|
||||
},
|
||||
|
||||
auditLogHeader: {
|
||||
padding: theme.spacing(2, 4),
|
||||
},
|
||||
|
||||
clickable: {
|
||||
cursor: "pointer",
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
|
||||
auditLogRowInfo: {
|
||||
auditLogHeaderInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
auditLogResume: {
|
||||
auditLogSummary: {
|
||||
...theme.typography.body1,
|
||||
fontFamily: "inherit",
|
||||
display: "block",
|
||||
},
|
||||
|
||||
auditLogTime: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
},
|
||||
|
||||
auditLogInfo: {
|
||||
...theme.typography.body2,
|
||||
fontSize: 12,
|
||||
fontFamily: "inherit",
|
||||
@@ -178,18 +223,20 @@ const useStyles = makeStyles((theme) => ({
|
||||
display: "block",
|
||||
},
|
||||
|
||||
auditLogRight: {
|
||||
width: "auto",
|
||||
},
|
||||
|
||||
auditLogExtraInfo: {
|
||||
...theme.typography.body2,
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
color: theme.palette.text.secondary,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
// offset the absence of the arrow icon on diff-less logs
|
||||
columnWithoutDiff: {
|
||||
marginLeft: "24px",
|
||||
},
|
||||
|
||||
fullWidth: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
httpStatusPill: {
|
||||
fontSize: 10,
|
||||
height: 20,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
fontWeight: 600,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -43,7 +43,7 @@ export const BuildRow: React.FC<BuildRowProps> = ({ build }) => {
|
||||
<BuildAvatar build={build} />
|
||||
<div>
|
||||
<Stack
|
||||
className={styles.buildResume}
|
||||
className={styles.buildSummary}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
@@ -113,7 +113,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
borderBottom: 0,
|
||||
},
|
||||
|
||||
buildResume: {
|
||||
buildSummary: {
|
||||
...theme.typography.body1,
|
||||
fontFamily: "inherit",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import formatRelative from "date-fns/formatRelative"
|
||||
import { FC } from "react"
|
||||
|
||||
export interface TableDateRow {
|
||||
date: Date
|
||||
}
|
||||
|
||||
export const TableDateRow: FC<TableDateRow> = ({ date }) => {
|
||||
const styles = useStyles()
|
||||
// We only want the message related to the date since the time is displayed
|
||||
// inside of the build row
|
||||
const displayDate = formatRelative(date, new Date()).split("at")[0]
|
||||
|
||||
return (
|
||||
<TableRow className={styles.dateRow}>
|
||||
<TableCell className={styles.dateCell} title={date.toLocaleDateString()}>
|
||||
{displayDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
dateRow: {
|
||||
background: theme.palette.background.paper,
|
||||
|
||||
"&:not(:first-child) td": {
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
},
|
||||
|
||||
dateCell: {
|
||||
padding: `${theme.spacing(1, 4)} !important`,
|
||||
background: `${theme.palette.background.paperLight} !important`,
|
||||
fontSize: 12,
|
||||
position: "relative",
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
}))
|
||||
@@ -2,7 +2,6 @@ import Table from "@material-ui/core/Table"
|
||||
import TableBody from "@material-ui/core/TableBody"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableContainer from "@material-ui/core/TableContainer"
|
||||
import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import { AuditLog } from "api/typesGenerated"
|
||||
import { AuditLogRow } from "components/AuditLogRow/AuditLogRow"
|
||||
@@ -16,9 +15,10 @@ import {
|
||||
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
|
||||
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { TableDateRow } from "components/TableDateRow/TableDateRow"
|
||||
import { TableLoader } from "components/TableLoader/TableLoader"
|
||||
import { AuditHelpTooltip } from "components/Tooltips"
|
||||
import { FC } from "react"
|
||||
import { FC, Fragment } from "react"
|
||||
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
|
||||
|
||||
export const Language = {
|
||||
@@ -37,6 +37,27 @@ const presetFilters = [
|
||||
{ query: "resource_type:user action:delete", name: "Deleted users" },
|
||||
]
|
||||
|
||||
const groupAuditLogsByDate = (auditLogs?: AuditLog[]) => {
|
||||
const auditLogsByDate: Record<string, AuditLog[]> = {}
|
||||
|
||||
if (!auditLogs) {
|
||||
return
|
||||
}
|
||||
|
||||
auditLogs.forEach((auditLog) => {
|
||||
const dateKey = new Date(auditLog.time).toDateString()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (auditLogsByDate[dateKey]) {
|
||||
auditLogsByDate[dateKey].push(auditLog)
|
||||
} else {
|
||||
auditLogsByDate[dateKey] = [auditLog]
|
||||
}
|
||||
})
|
||||
|
||||
return auditLogsByDate
|
||||
}
|
||||
|
||||
export interface AuditPageViewProps {
|
||||
auditLogs?: AuditLog[]
|
||||
count?: number
|
||||
@@ -54,7 +75,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
}) => {
|
||||
const isLoading = auditLogs === undefined || count === undefined
|
||||
const isEmpty = !isLoading && auditLogs.length === 0
|
||||
const hasResults = !isLoading && auditLogs.length > 0
|
||||
const auditLogsByDate = groupAuditLogsByDate(auditLogs)
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
@@ -77,17 +98,23 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingLeft: 32 }}>Logs</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
{hasResults &&
|
||||
auditLogs.map((auditLog) => (
|
||||
<AuditLogRow auditLog={auditLog} key={auditLog.id} />
|
||||
))}
|
||||
|
||||
{auditLogsByDate &&
|
||||
Object.keys(auditLogsByDate).map((dateStr) => {
|
||||
const auditLogs = auditLogsByDate[dateStr]
|
||||
|
||||
return (
|
||||
<Fragment key={dateStr}>
|
||||
<TableDateRow date={new Date(dateStr)} />
|
||||
{auditLogs.map((log) => (
|
||||
<AuditLogRow key={log.id} auditLog={log} />
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
{isEmpty && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
|
||||
Reference in New Issue
Block a user