auditLogList.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {ActivityAvatar} from 'sentry/components/activity/item/avatar';
  4. import UserAvatar from 'sentry/components/avatar/userAvatar';
  5. import DateTime from 'sentry/components/dateTime';
  6. import SelectControl from 'sentry/components/forms/controls/selectControl';
  7. import Link from 'sentry/components/links/link';
  8. import type {CursorHandler} from 'sentry/components/pagination';
  9. import Pagination from 'sentry/components/pagination';
  10. import PanelTable from 'sentry/components/panels/panelTable';
  11. import Tag from 'sentry/components/tag';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {t, tct} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {AuditLog, Organization, User} from 'sentry/types';
  16. import {shouldUse24Hours} from 'sentry/utils/dates';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import useProjects from 'sentry/utils/useProjects';
  19. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  20. import {
  21. projectDetectorSettingsId,
  22. retentionPrioritiesLabels,
  23. } from 'sentry/views/settings/projectPerformance/projectPerformance';
  24. const avatarStyle = {
  25. width: 36,
  26. height: 36,
  27. marginRight: 8,
  28. };
  29. const getAvatarDisplay = (logEntryUser: User | undefined) => {
  30. // Display Sentry's avatar for system or superuser-initiated events
  31. if (
  32. logEntryUser?.isSuperuser ||
  33. (logEntryUser?.name === 'Sentry' && logEntryUser?.email === undefined)
  34. ) {
  35. return <SentryAvatar type="system" size={36} />;
  36. }
  37. // Display user's avatar for non-superusers-initiated events
  38. if (logEntryUser !== undefined) {
  39. return <UserAvatar style={avatarStyle} user={logEntryUser} />;
  40. }
  41. return null;
  42. };
  43. const addUsernameDisplay = (logEntryUser: User | undefined) => {
  44. if (logEntryUser?.isSuperuser) {
  45. return (
  46. <Name data-test-id="actor-name">
  47. {logEntryUser.name}
  48. <StaffTag>{t('Sentry Staff')}</StaffTag>
  49. </Name>
  50. );
  51. }
  52. if (logEntryUser !== undefined) {
  53. return <Name data-test-id="actor-name">{logEntryUser.name}</Name>;
  54. }
  55. return null;
  56. };
  57. const getTypeDisplay = (event: string) => {
  58. if (event.startsWith('rule.')) {
  59. return event.replace('rule.', 'issue-alert.');
  60. }
  61. if (event.startsWith('alertrule.')) {
  62. return event.replace('alertrule.', 'metric-alert.');
  63. }
  64. return event;
  65. };
  66. const getEventOptions = (eventTypes: string[] | null) =>
  67. eventTypes
  68. ?.map(type => {
  69. // Having both rule.x and alertrule.x may be confusing, so we'll replace their labels to be more descriptive.
  70. // We need to maintain the values here so we still fetch the correct audit log events from the backend should we want
  71. // to filter.
  72. // See https://github.com/getsentry/sentry/issues/46997
  73. if (type.startsWith('rule.')) {
  74. return {
  75. label: type.replace('rule.', 'issue-alert.'),
  76. value: type,
  77. };
  78. }
  79. if (type.startsWith('alertrule.')) {
  80. return {
  81. label: type.replace('alertrule.', 'metric-alert.'),
  82. value: type,
  83. };
  84. }
  85. return {
  86. label: type,
  87. value: type,
  88. };
  89. })
  90. .sort((a, b) => a.label.localeCompare(b.label));
  91. function AuditNote({
  92. entry,
  93. orgSlug,
  94. }: {
  95. entry: NonNullable<AuditLog>;
  96. orgSlug: Organization['slug'];
  97. }) {
  98. const {projects} = useProjects();
  99. const project = projects.find(p => p.id === String(entry.data.id));
  100. if (entry.event.startsWith('rule.')) {
  101. return <Note>{entry.note.replace('rule', 'issue alert rule')}</Note>;
  102. }
  103. if (!project) {
  104. return <Note>{entry.note}</Note>;
  105. }
  106. if (entry.event === 'project.create') {
  107. return (
  108. <Note>
  109. {tct('Created project [projectSettingsLink]', {
  110. projectSettingsLink: (
  111. <Link to={`/settings/${orgSlug}/projects/${project.slug}/`}>
  112. {entry.data.slug}
  113. </Link>
  114. ),
  115. })}
  116. </Note>
  117. );
  118. }
  119. if (entry.event === 'project.edit') {
  120. if (entry.data.old_slug && entry.data.new_slug) {
  121. return (
  122. <Note>
  123. {tct('Renamed project slug from [old-slug] to [new-slug]', {
  124. 'old-slug': entry.data.old_slug,
  125. 'new-slug': (
  126. <Link to={`/settings/${orgSlug}/projects/${entry.data.new_slug}/`}>
  127. {entry.data.new_slug}
  128. </Link>
  129. ),
  130. })}
  131. </Note>
  132. );
  133. }
  134. return (
  135. <Note>
  136. {tct('Edited project [projectSettingsLink] [note]', {
  137. projectSettingsLink: (
  138. <Link to={`/settings/${orgSlug}/projects/${project.slug}/`}>
  139. {entry.data.slug}
  140. </Link>
  141. ),
  142. note: entry.note.replace('edited project settings ', ''),
  143. })}
  144. </Note>
  145. );
  146. }
  147. if (entry.event === 'project.change-performance-issue-detection') {
  148. return (
  149. <Note>
  150. {tct('Edited project [projectSettingsLink] [note]', {
  151. projectSettingsLink: (
  152. <Link
  153. to={`/settings/${orgSlug}/projects/${project.slug}/performance/#${projectDetectorSettingsId}`}
  154. >
  155. {entry.data.slug} performance issue detector settings
  156. </Link>
  157. ),
  158. note: entry.note.replace(
  159. 'edited project performance issue detector settings ',
  160. ''
  161. ),
  162. })}
  163. </Note>
  164. );
  165. }
  166. if (entry.event === 'sampling_priority.enabled') {
  167. return (
  168. <Note>
  169. {tct(
  170. 'Enabled retention priority "[biasLabel]" in project [samplingInProjectSettingsLink]',
  171. {
  172. samplingInProjectSettingsLink: (
  173. <Link to={`/settings/${orgSlug}/projects/${project.slug}/performance/`}>
  174. {entry.data.slug}
  175. </Link>
  176. ),
  177. biasLabel: retentionPrioritiesLabels[entry.data.name],
  178. }
  179. )}
  180. </Note>
  181. );
  182. }
  183. if (entry.event === 'sampling_priority.disabled') {
  184. return (
  185. <Note>
  186. {tct(
  187. 'Disabled retention priority "[biasLabel]" in project [samplingInProjectSettingsLink]',
  188. {
  189. samplingInProjectSettingsLink: (
  190. <Link to={`/settings/${orgSlug}/projects/${project.slug}/performance/`}>
  191. {entry.data.slug}
  192. </Link>
  193. ),
  194. biasLabel: retentionPrioritiesLabels[entry.data.name],
  195. }
  196. )}
  197. </Note>
  198. );
  199. }
  200. if (entry.event === 'project.ownership-rule.edit') {
  201. return (
  202. <Note>
  203. {tct('Modified ownership rules in project [projectSettingsLink]', {
  204. projectSettingsLink: (
  205. <Link to={`/settings/${orgSlug}/projects/${project.slug}/`}>
  206. {entry.data.slug}
  207. </Link>
  208. ),
  209. })}
  210. </Note>
  211. );
  212. }
  213. return <Note>{entry.note}</Note>;
  214. }
  215. type Props = {
  216. entries: AuditLog[] | null;
  217. eventType: string | undefined;
  218. eventTypes: string[] | null;
  219. isLoading: boolean;
  220. onCursor: CursorHandler | undefined;
  221. onEventSelect: (value: string) => void;
  222. pageLinks: string | null;
  223. };
  224. function AuditLogList({
  225. isLoading,
  226. pageLinks,
  227. entries,
  228. eventType,
  229. eventTypes,
  230. onCursor,
  231. onEventSelect,
  232. }: Props) {
  233. const is24Hours = shouldUse24Hours();
  234. const organization = useOrganization();
  235. const hasEntries = entries && entries.length > 0;
  236. const ipv4Length = 15;
  237. const action = (
  238. <EventSelector
  239. clearable
  240. isDisabled={isLoading}
  241. name="eventFilter"
  242. value={eventType}
  243. placeholder={t('Select Action: ')}
  244. options={getEventOptions(eventTypes)}
  245. onChange={options => {
  246. onEventSelect(options?.value);
  247. }}
  248. />
  249. );
  250. return (
  251. <div>
  252. <SettingsPageHeader title={t('Audit Log')} action={action} />
  253. <PanelTable
  254. headers={[t('Member'), t('Action'), t('IP'), t('Time')]}
  255. isEmpty={!hasEntries && entries?.length === 0}
  256. emptyMessage={t('No audit entries available')}
  257. isLoading={isLoading}
  258. >
  259. {(entries ?? []).map(entry => {
  260. if (!entry) {
  261. return null;
  262. }
  263. return (
  264. <Fragment key={entry.id}>
  265. <UserInfo>
  266. <div>{getAvatarDisplay(entry.actor)}</div>
  267. <NameContainer>
  268. {addUsernameDisplay(entry.actor)}
  269. <AuditNote entry={entry} orgSlug={organization.slug} />
  270. </NameContainer>
  271. </UserInfo>
  272. <FlexCenter>
  273. <MonoDetail>{getTypeDisplay(entry.event)}</MonoDetail>
  274. </FlexCenter>
  275. <FlexCenter>
  276. {entry.ipAddress && (
  277. <IpAddressOverflow>
  278. <Tooltip
  279. title={entry.ipAddress}
  280. disabled={entry.ipAddress.length <= ipv4Length}
  281. >
  282. <MonoDetail>{entry.ipAddress}</MonoDetail>
  283. </Tooltip>
  284. </IpAddressOverflow>
  285. )}
  286. </FlexCenter>
  287. <TimestampInfo>
  288. <DateTime dateOnly date={entry.dateCreated} />
  289. <DateTime
  290. timeOnly
  291. format={is24Hours ? 'HH:mm zz' : 'LT zz'}
  292. date={entry.dateCreated}
  293. />
  294. </TimestampInfo>
  295. </Fragment>
  296. );
  297. })}
  298. </PanelTable>
  299. {pageLinks && <Pagination pageLinks={pageLinks} onCursor={onCursor} />}
  300. </div>
  301. );
  302. }
  303. const SentryAvatar = styled(ActivityAvatar)`
  304. margin-right: ${space(1)};
  305. `;
  306. const Name = styled('strong')`
  307. font-size: ${p => p.theme.fontSizeMedium};
  308. `;
  309. const StaffTag = styled(Tag)`
  310. padding: ${space(1)};
  311. `;
  312. const EventSelector = styled(SelectControl)`
  313. width: 250px;
  314. `;
  315. const UserInfo = styled('div')`
  316. display: flex;
  317. align-items: center;
  318. line-height: 1.2;
  319. font-size: ${p => p.theme.fontSizeSmall};
  320. min-width: 250px;
  321. `;
  322. const NameContainer = styled('div')`
  323. display: flex;
  324. flex-direction: column;
  325. justify-content: center;
  326. `;
  327. const Note = styled('div')`
  328. font-size: ${p => p.theme.fontSizeSmall};
  329. word-break: break-word;
  330. margin-top: ${space(0.5)};
  331. `;
  332. const FlexCenter = styled('div')`
  333. display: flex;
  334. align-items: center;
  335. `;
  336. const IpAddressOverflow = styled('div')`
  337. ${p => p.theme.overflowEllipsis};
  338. min-width: 90px;
  339. `;
  340. const MonoDetail = styled('code')`
  341. font-size: ${p => p.theme.fontSizeMedium};
  342. white-space: no-wrap;
  343. `;
  344. const TimestampInfo = styled('div')`
  345. display: grid;
  346. grid-template-rows: auto auto;
  347. gap: ${space(1)};
  348. font-size: ${p => p.theme.fontSizeMedium};
  349. `;
  350. export default AuditLogList;