mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: embed audit log in deployment settings page (#14023)
* Move audit page to /deployment/audit The existing link remains but will redirect to the new URL. If multi-org is not enabled, nothing changes. * Redirect organization audit page to site-wide audit page * Always wrap audit log filters when in deployment settings Otherwise the input is teeny tiny and barely fits a few characters. Normally we only wrap when the screen shrinks. Again, no change if multi-org is not enabled. This also makes the filter menus take up available space when wrapping (*does* apply to non-multi-org setups as well). * Show audit log details in a tooltip If multi-org is not enabled, details continue to be shown inline.
This commit is contained in:
@@ -52,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
|
||||
<SelectMenuTrigger>
|
||||
<SelectMenuButton
|
||||
startIcon={selectedOption?.startIcon}
|
||||
css={{ width }}
|
||||
css={{ width, flexGrow: 1 }}
|
||||
aria-label={label}
|
||||
>
|
||||
{selectedOption?.label ?? placeholder}
|
||||
|
||||
@@ -142,6 +142,8 @@ type FilterProps = {
|
||||
error?: unknown;
|
||||
options?: ReactNode;
|
||||
presets: PresetFilter[];
|
||||
/** Set to true if there is not much horizontal space. */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const Filter: FC<FilterProps> = ({
|
||||
@@ -154,6 +156,7 @@ export const Filter: FC<FilterProps> = ({
|
||||
learnMoreLabel2,
|
||||
learnMoreLink2,
|
||||
presets,
|
||||
compact,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
// Storing local copy of the filter query so that it can be updated more
|
||||
@@ -184,7 +187,10 @@ export const Filter: FC<FilterProps> = ({
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
flexWrap: "nowrap",
|
||||
// For now compact just means immediately wrapping, but maybe we should
|
||||
// have a collapsible section or consolidate into one menu or something.
|
||||
// TODO: Remove separate compact mode once multi-org is stable.
|
||||
flexWrap: compact ? "wrap" : "nowrap",
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
flexWrap: "wrap",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PopoverTrigger,
|
||||
usePopover,
|
||||
} from "components/Popover/Popover";
|
||||
import { USERS_LINK } from "modules/navigation";
|
||||
import { AUDIT_LINK, USERS_LINK } from "modules/navigation";
|
||||
|
||||
interface DeploymentDropdownProps {
|
||||
canViewDeployment: boolean;
|
||||
@@ -114,7 +114,7 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
|
||||
{canViewAllUsers && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to={USERS_LINK}
|
||||
to={canViewOrganizations ? `/deployment${USERS_LINK}` : USERS_LINK}
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
@@ -124,7 +124,7 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
|
||||
{canViewAuditLog && (
|
||||
<MenuItem
|
||||
component={NavLink}
|
||||
to="/audit"
|
||||
to={canViewOrganizations ? `/deployment${AUDIT_LINK}` : AUDIT_LINK}
|
||||
css={styles.menuItem}
|
||||
onClick={onPopoverClose}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
* @fileoverview TODO: centralize navigation code here! URL constants, URL formatting, all of it
|
||||
*/
|
||||
|
||||
export const USERS_LINK = `/users?filter=${encodeURIComponent(
|
||||
"status:active",
|
||||
)}`;
|
||||
export function withFilter(path: string, filter: string) {
|
||||
return path + (filter ? `?filter=${encodeURIComponent(filter)}` : "");
|
||||
}
|
||||
|
||||
export const AUDIT_LINK = "/audit";
|
||||
|
||||
export const USERS_LINK = withFilter("/users", "status:active");
|
||||
|
||||
@@ -51,8 +51,6 @@ interface AuditFilterProps {
|
||||
}
|
||||
|
||||
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
||||
// Use a smaller width if including the organization filter.
|
||||
const width = menus.organization && 175;
|
||||
return (
|
||||
<Filter
|
||||
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
|
||||
@@ -60,13 +58,16 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
||||
isLoading={menus.user.isInitializing}
|
||||
filter={filter}
|
||||
error={error}
|
||||
// There is not much space with the sidebar and four filters, so in this
|
||||
// case we will use the compact mode.
|
||||
compact={Boolean(menus.organization)}
|
||||
options={
|
||||
<>
|
||||
<ResourceTypeMenu width={width} menu={menus.resourceType} />
|
||||
<ActionMenu width={width} menu={menus.action} />
|
||||
<UserMenu width={width} menu={menus.user} />
|
||||
<ResourceTypeMenu menu={menus.resourceType} />
|
||||
<ActionMenu menu={menus.action} />
|
||||
<UserMenu menu={menus.user} />
|
||||
{menus.organization && (
|
||||
<OrganizationsMenu width={width} menu={menus.organization} />
|
||||
<OrganizationsMenu menu={menus.organization} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -113,3 +113,10 @@ export const SecretDiffValue: Story = {
|
||||
auditLog: MockAuditLogGitSSH,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOrganization: Story = {
|
||||
args: {
|
||||
auditLog: MockAuditLog,
|
||||
showOrgDetails: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Link from "@mui/material/Link";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { type FC, useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import userAgentParser from "ua-parser-js";
|
||||
@@ -115,42 +117,80 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
{auditLog.ip && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<>IP: </>
|
||||
<strong>{auditLog.ip}</strong>
|
||||
</span>
|
||||
)}
|
||||
{os.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<>OS: </>
|
||||
<strong>{os.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{browser.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<>Browser: </>
|
||||
<strong>
|
||||
{browser.name} {browser.version}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showOrgDetails && auditLog.organization && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<>Org: </>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/organizations/${auditLog.organization.name}`}
|
||||
>
|
||||
{/* With multi-org, there is not enough space so show
|
||||
everything in a tooltip. */}
|
||||
{showOrgDetails ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<div css={styles.auditLogInfoTooltip}>
|
||||
{auditLog.ip && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>IP:</h4>
|
||||
<div>{auditLog.ip}</div>
|
||||
</div>
|
||||
)}
|
||||
{os.name && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>OS:</h4>
|
||||
<div>{os.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{browser.name && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>Browser:</h4>
|
||||
<div>
|
||||
{browser.name} {browser.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auditLog.organization && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>
|
||||
Organization:
|
||||
</h4>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/organizations/${auditLog.organization.name}`}
|
||||
>
|
||||
{auditLog.organization.display_name ||
|
||||
auditLog.organization.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<InfoOutlined
|
||||
css={(theme) => ({
|
||||
fontSize: 20,
|
||||
color: theme.palette.info.light,
|
||||
})}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
{auditLog.ip && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>IP: </span>
|
||||
<strong>{auditLog.ip}</strong>
|
||||
</span>
|
||||
)}
|
||||
{os.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>OS: </span>
|
||||
<strong>{os.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{browser.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>Browser: </span>
|
||||
<strong>
|
||||
{auditLog.organization.display_name ||
|
||||
auditLog.organization.name}
|
||||
{browser.name} {browser.version}
|
||||
</strong>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</Stack>
|
||||
</span>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Pill
|
||||
css={styles.httpStatusPill}
|
||||
@@ -212,6 +252,20 @@ const styles = {
|
||||
display: "block",
|
||||
}),
|
||||
|
||||
auditLogInfoHeader: (theme) => ({
|
||||
margin: 0,
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: 14,
|
||||
lineHeight: "150%",
|
||||
fontWeight: 600,
|
||||
}),
|
||||
|
||||
auditLogInfoTooltip: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
// offset the absence of the arrow icon on diff-less logs
|
||||
columnWithoutDiff: {
|
||||
marginLeft: "24px",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams, Navigate, useLocation } from "react-router-dom";
|
||||
import { paginatedAudits } from "api/queries/audits";
|
||||
import { useFilter } from "components/Filter/filter";
|
||||
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||
@@ -19,6 +19,8 @@ import { AuditPageView } from "./AuditPageView";
|
||||
const AuditPage: FC = () => {
|
||||
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
|
||||
const { experiments } = useDashboard();
|
||||
const location = useLocation();
|
||||
const isMultiOrg = experiments.includes("multi-organization");
|
||||
|
||||
/**
|
||||
* There is an implicit link between auditsQuery and filter via the
|
||||
@@ -70,6 +72,13 @@ const AuditPage: FC = () => {
|
||||
}),
|
||||
});
|
||||
|
||||
// TODO: Once multi-org is stable, we should place this redirect into the
|
||||
// router directly, if we still need to maintain it (for users who are
|
||||
// typing the old URL manually or have it bookmarked).
|
||||
if (isMultiOrg && location.pathname !== "/deployment/audit") {
|
||||
return <Navigate to={`/deployment/audit${location.search}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -82,7 +91,7 @@ const AuditPage: FC = () => {
|
||||
isAuditLogVisible={isAuditLogVisible}
|
||||
auditsQuery={auditsQuery}
|
||||
error={auditsQuery.error}
|
||||
showOrgDetails={experiments.includes("multi-organization")}
|
||||
showOrgDetails={isMultiOrg}
|
||||
filterProps={{
|
||||
filter,
|
||||
error: auditsQuery.error,
|
||||
@@ -90,9 +99,7 @@ const AuditPage: FC = () => {
|
||||
user: userMenu,
|
||||
action: actionMenu,
|
||||
resourceType: resourceTypeMenu,
|
||||
organization: experiments.includes("multi-organization")
|
||||
? organizationsMenu
|
||||
: undefined,
|
||||
organization: isMultiOrg ? organizationsMenu : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -57,8 +57,20 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
const isEmpty = !isLoading && auditLogs?.length === 0;
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader>
|
||||
<Margins
|
||||
css={{
|
||||
// When acting as a deployment settings page, there is already padding.
|
||||
// TODO: When multi-org is stable we should move this to the deployment
|
||||
// settings dir, use the standard header, and remove these margins.
|
||||
padding: showOrgDetails ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<PageHeader
|
||||
css={{
|
||||
// When acting as a deployment settings page, there is already padding.
|
||||
paddingTop: showOrgDetails ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<PageHeaderTitle>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<span>{Language.title}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Stack } from "components/Stack/Stack";
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { USERS_LINK } from "modules/navigation";
|
||||
import { AUDIT_LINK, USERS_LINK, withFilter } from "modules/navigation";
|
||||
import { useOrganizationSettings } from "./ManagementSettingsLayout";
|
||||
|
||||
export const Sidebar: FC = () => {
|
||||
@@ -103,6 +103,9 @@ const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({
|
||||
{!organizationsEnabled && (
|
||||
<SidebarNavSubItem href="groups">Groups</SidebarNavSubItem>
|
||||
)}
|
||||
<SidebarNavSubItem href={AUDIT_LINK.slice(1)}>
|
||||
Auditing
|
||||
</SidebarNavSubItem>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,8 +151,14 @@ export const OrganizationSettingsNavigation: FC<
|
||||
<SidebarNavSubItem href={urlForSubpage(organization.name, "groups")}>
|
||||
Groups
|
||||
</SidebarNavSubItem>
|
||||
{/* For now redirect to the site-wide audit page with the organization
|
||||
pre-filled into the filter. Based on user feedback we might want
|
||||
to serve a copy of the audit page or even delete this link. */}
|
||||
<SidebarNavSubItem
|
||||
href={urlForSubpage(organization.name, "auditing")}
|
||||
href={`/deployment${withFilter(
|
||||
AUDIT_LINK,
|
||||
`organization:${organization.name}`,
|
||||
)}`}
|
||||
>
|
||||
Auditing
|
||||
</SidebarNavSubItem>
|
||||
|
||||
@@ -406,6 +406,7 @@ export const router = createBrowserRouter(
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/create" element={<CreateUserPage />} />
|
||||
{groupsRouter()}
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/settings" element={<UserSettingsLayout />}>
|
||||
|
||||
Reference in New Issue
Block a user