mirror of
https://github.com/coder/coder.git
synced 2026-10-25 09:42:55 +08:00
Add audit links/kira pilot (#5156)
* got links working * added translations * fixed translation * added translation for unavailable ip * added support for group, template, user links * cleaned up string * added deleted label * querying for workspace id * remove prints * fix/write tests * PR feedback pt 1 * PR feedback part 2
This commit is contained in:
@@ -67,6 +67,8 @@ export interface AuditLog {
|
||||
readonly status_code: number
|
||||
readonly additional_fields: Record<string, string>
|
||||
readonly description: string
|
||||
readonly resource_link: string
|
||||
readonly is_deleted: boolean
|
||||
readonly user?: User
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
MockAuditLog,
|
||||
MockAuditLogWithWorkspaceBuild,
|
||||
} from "testHelpers/entities"
|
||||
import { AuditLogDescription } from "./AuditLogDescription"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { screen } from "@testing-library/react"
|
||||
|
||||
const getByTextContent = (text: string) => {
|
||||
return screen.getByText((_, element) => {
|
||||
const hasText = (element: Element | null) => element?.textContent === text
|
||||
const elementHasText = hasText(element)
|
||||
const childrenDontHaveText = Array.from(element?.children || []).every(
|
||||
(child) => !hasText(child),
|
||||
)
|
||||
return elementHasText && childrenDontHaveText
|
||||
})
|
||||
}
|
||||
describe("AuditLogDescription", () => {
|
||||
it("renders the correct string for a workspace create audit log", async () => {
|
||||
render(<AuditLogDescription auditLog={MockAuditLog} />)
|
||||
|
||||
expect(
|
||||
getByTextContent("TestUser created workspace bruno-dev"),
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it("renders the correct string for a workspace_build stop audit log", async () => {
|
||||
render(<AuditLogDescription auditLog={MockAuditLogWithWorkspaceBuild} />)
|
||||
|
||||
expect(
|
||||
getByTextContent("TestUser stopped build for workspace test2"),
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it("renders the correct string for a workspace_build audit log with a duplicate word", async () => {
|
||||
const AuditLogWithRepeat = {
|
||||
...MockAuditLogWithWorkspaceBuild,
|
||||
additional_fields: {
|
||||
workspaceName: "workspace",
|
||||
},
|
||||
}
|
||||
render(<AuditLogDescription auditLog={AuditLogWithRepeat} />)
|
||||
|
||||
expect(
|
||||
getByTextContent("TestUser stopped build for workspace workspace"),
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FC } from "react"
|
||||
import { AuditLog } from "api/typesGenerated"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import i18next from "i18next"
|
||||
|
||||
export const AuditLogDescription: FC<{ auditLog: AuditLog }> = ({
|
||||
auditLog,
|
||||
}): JSX.Element => {
|
||||
const classes = useStyles()
|
||||
const { t } = i18next
|
||||
|
||||
let target = auditLog.resource_target.trim()
|
||||
|
||||
// audit logs with a resource_type of workspace build use workspace name as a target
|
||||
if (
|
||||
auditLog.resource_type === "workspace_build" &&
|
||||
auditLog.additional_fields.workspaceName
|
||||
) {
|
||||
target = auditLog.additional_fields.workspaceName.trim()
|
||||
}
|
||||
|
||||
// SSH key entries have no links
|
||||
if (auditLog.resource_type === "git_ssh_key") {
|
||||
return (
|
||||
<span>
|
||||
{auditLog.description
|
||||
.replace("{user}", `${auditLog.user?.username.trim()}`)
|
||||
.replace("{target}", `${target}`)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const truncatedDescription = auditLog.description
|
||||
.replace("{user}", `${auditLog.user?.username.trim()}`)
|
||||
.replace("{target}", "")
|
||||
|
||||
return (
|
||||
<span>
|
||||
{truncatedDescription}
|
||||
{auditLog.resource_link ? (
|
||||
<Link component={RouterLink} to={auditLog.resource_link}>
|
||||
<strong>{target}</strong>
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{target}</strong>
|
||||
)}
|
||||
{auditLog.is_deleted && (
|
||||
<span className={classes.deletedLabel}>
|
||||
<> {t("auditLog:table.logRow.deletedLabel")}</>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
deletedLabel: {
|
||||
...theme.typography.caption,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MockAuditLog,
|
||||
MockAuditLog2,
|
||||
MockAuditLogWithWorkspaceBuild,
|
||||
MockAuditLogWithDeletedResource,
|
||||
} from "testHelpers/entities"
|
||||
import { AuditLogRow, AuditLogRowProps } from "./AuditLogRow"
|
||||
|
||||
@@ -66,3 +67,8 @@ export const WithWorkspaceBuild = Template.bind({})
|
||||
WithWorkspaceBuild.args = {
|
||||
auditLog: MockAuditLogWithWorkspaceBuild,
|
||||
}
|
||||
|
||||
export const DeletedResource = Template.bind({})
|
||||
DeletedResource.args = {
|
||||
auditLog: MockAuditLogWithDeletedResource,
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { readableActionMessage } from "./AuditLogRow"
|
||||
import {
|
||||
MockAuditLog,
|
||||
MockAuditLogWithWorkspaceBuild,
|
||||
} from "testHelpers/entities"
|
||||
|
||||
describe("readableActionMessage()", () => {
|
||||
it("renders the correct string for a workspaceBuild audit log", async () => {
|
||||
// When
|
||||
const friendlyString = readableActionMessage(MockAuditLogWithWorkspaceBuild)
|
||||
|
||||
// Then
|
||||
expect(friendlyString).toBe(
|
||||
"<strong>TestUser</strong> stopped workspace build for <strong>test2</strong>",
|
||||
)
|
||||
})
|
||||
it("renders the correct string for a workspaceBuild audit log with a duplicate word", async () => {
|
||||
// When
|
||||
const AuditLogWithRepeat = {
|
||||
...MockAuditLogWithWorkspaceBuild,
|
||||
additional_fields: {
|
||||
workspaceName: "workspace",
|
||||
},
|
||||
}
|
||||
const friendlyString = readableActionMessage(AuditLogWithRepeat)
|
||||
|
||||
// Then
|
||||
expect(friendlyString).toBe(
|
||||
"<strong>TestUser</strong> stopped workspace build for <strong>workspace</strong>",
|
||||
)
|
||||
})
|
||||
it("renders the correct string for a workspace audit log", async () => {
|
||||
// When
|
||||
const friendlyString = readableActionMessage(MockAuditLog)
|
||||
|
||||
// Then
|
||||
expect(friendlyString).toBe(
|
||||
"<strong>TestUser</strong> created workspace <strong>bruno-dev</strong>",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,22 +14,8 @@ import { useState } from "react"
|
||||
import { PaletteIndex } from "theme/palettes"
|
||||
import userAgentParser from "ua-parser-js"
|
||||
import { AuditLogDiff } from "./AuditLogDiff"
|
||||
|
||||
export const readableActionMessage = (auditLog: AuditLog): string => {
|
||||
let target = auditLog.resource_target.trim()
|
||||
|
||||
// audit logs with a resource_type of workspace build use workspace name as a target
|
||||
if (
|
||||
auditLog.resource_type === "workspace_build" &&
|
||||
auditLog.additional_fields.workspaceName
|
||||
) {
|
||||
target = auditLog.additional_fields.workspaceName.trim()
|
||||
}
|
||||
|
||||
return auditLog.description
|
||||
.replace("{user}", `<strong>${auditLog.user?.username.trim()}</strong>`)
|
||||
.replace("{target}", `<strong>${target}</strong>`)
|
||||
}
|
||||
import i18next from "i18next"
|
||||
import { AuditLogDescription } from "./AuditLogDescription"
|
||||
|
||||
const httpStatusColor = (httpStatus: number): PaletteIndex => {
|
||||
if (httpStatus >= 300 && httpStatus < 500) {
|
||||
@@ -54,14 +40,14 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
defaultIsDiffOpen = false,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const { t } = i18next
|
||||
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen)
|
||||
const diffs = Object.entries(auditLog.diff)
|
||||
const shouldDisplayDiff = diffs.length > 0
|
||||
const { os, browser } = userAgentParser(auditLog.user_agent)
|
||||
const notAvailableLabel = "Not available"
|
||||
const displayBrowserInfo = browser.name
|
||||
? `${browser.name} ${browser.version}`
|
||||
: notAvailableLabel
|
||||
: t("auditLog:table.logRow.notAvailable")
|
||||
|
||||
const toggle = () => {
|
||||
if (shouldDisplayDiff) {
|
||||
@@ -115,11 +101,7 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
alignItems="baseline"
|
||||
spacing={1}
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: readableActionMessage(auditLog),
|
||||
}}
|
||||
/>
|
||||
<AuditLogDescription auditLog={auditLog} />
|
||||
<span className={styles.auditLogTime}>
|
||||
{new Date(auditLog.time).toLocaleTimeString()}
|
||||
</span>
|
||||
@@ -128,15 +110,27 @@ export const AuditLogRow: React.FC<AuditLogRowProps> = ({
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
<span className={styles.auditLogInfo}>
|
||||
IP: <strong>{auditLog.ip ?? notAvailableLabel}</strong>
|
||||
<>{t("auditLog:table.logRow.ip")}</>
|
||||
<strong>
|
||||
{auditLog.ip
|
||||
? auditLog.ip
|
||||
: t("auditLog:table.logRow.notAvailable")}
|
||||
</strong>
|
||||
</span>
|
||||
|
||||
<span className={styles.auditLogInfo}>
|
||||
OS: <strong>{os.name ?? notAvailableLabel}</strong>
|
||||
<>{t("auditLog:table.logRow.os")}</>
|
||||
<strong>
|
||||
{os.name
|
||||
? os.name
|
||||
: // https://github.com/i18next/next-i18next/issues/1795
|
||||
t<string>("auditLog:table.logRow.notAvailable")}
|
||||
</strong>
|
||||
</span>
|
||||
|
||||
<span className={styles.auditLogInfo}>
|
||||
Browser: <strong>{displayBrowserInfo}</strong>
|
||||
<>{t("auditLog:table.logRow.browser")}</>
|
||||
<strong>{displayBrowserInfo}</strong>
|
||||
</span>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
},
|
||||
"table": {
|
||||
"emptyPage": "No audit logs available on this page",
|
||||
"noLogs": "No audit logs available"
|
||||
"noLogs": "No audit logs available",
|
||||
"logRow": {
|
||||
"deletedLabel": "(deleted)",
|
||||
"ip": "IP: ",
|
||||
"os": "OS: ",
|
||||
"browser": "Browser: ",
|
||||
"notAvailable": "Not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,6 +986,8 @@ export const MockAuditLog: TypesGen.AuditLog = {
|
||||
additional_fields: {},
|
||||
description: "{user} created workspace {target}",
|
||||
user: MockUser,
|
||||
resource_link: "/@admin/bruno-dev",
|
||||
is_deleted: false,
|
||||
}
|
||||
|
||||
export const MockAuditLog2: TypesGen.AuditLog = {
|
||||
@@ -1024,12 +1026,17 @@ export const MockAuditLogWithWorkspaceBuild: TypesGen.AuditLog = {
|
||||
request_id: "61555889-2875-475c-8494-f7693dd5d75b",
|
||||
action: "stop",
|
||||
resource_type: "workspace_build",
|
||||
description: "{user} stopped workspace build for {target}",
|
||||
description: "{user} stopped build for workspace {target}",
|
||||
additional_fields: {
|
||||
workspaceName: "test2",
|
||||
},
|
||||
}
|
||||
|
||||
export const MockAuditLogWithDeletedResource: TypesGen.AuditLog = {
|
||||
...MockAuditLog,
|
||||
is_deleted: true,
|
||||
}
|
||||
|
||||
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
|
||||
credits_consumed: 0,
|
||||
budget: 100,
|
||||
|
||||
Reference in New Issue
Block a user