chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,475 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Calendar,
ChipCombobox,
ChipInput,
ChipSelect,
type ComboboxOption,
Download,
Popover,
PopoverAnchor,
PopoverContent,
RefreshCw,
Search,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
import { isRecordLike } from '@sim/utils/object'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils'
import {
ActivityLog,
type ActivityLogEntry,
} from '@/app/workspace/[workspaceId]/settings/components/activity-log'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants'
import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs'
import type { TimeRange } from '@/stores/logs/filters/types'
const logger = createLogger('AuditLogs')
const REFRESH_SPINNER_DURATION_MS = 1000
/** Trimmed to the most commonly used granularities so the menu fits without scrolling. */
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
{ value: 'All time', label: 'All time' },
{ value: 'Past hour', label: 'Past hour' },
{ value: 'Past 6 hours', label: 'Past 6 hours' },
{ value: 'Past 24 hours', label: 'Past 24 hours' },
{ value: 'Past 3 days', label: 'Past 3 days' },
{ value: 'Past 7 days', label: 'Past 7 days' },
{ value: 'Past 30 days', label: 'Past 30 days' },
{ value: 'Custom range', label: 'Custom range' },
]
function formatResourceType(type: string): string {
return type
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
function formatAction(action: string): string {
return action.replace(/[._]/g, ' ')
}
function formatMetadataLabel(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function formatPrimitiveValue(value: string | number | boolean | null): string {
if (value === null) return '-'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
if (typeof value === 'number') return value.toLocaleString()
return value
}
function renderMetadataValue(value: unknown) {
if (value == null) return <span className='text-[var(--text-muted)]'>-</span>
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return <span className='text-[var(--text-primary)]'>{formatPrimitiveValue(value)}</span>
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className='text-[var(--text-muted)]'>None</span>
}
const hasComplexValues = value.some((item) => typeof item === 'object' && item !== null)
if (!hasComplexValues) {
return (
<span className='text-[var(--text-primary)]'>
{value
.map((item) => formatPrimitiveValue((item as string | number | boolean | null) ?? null))
.join(', ')}
</span>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
if (isRecordLike(value)) {
const entries = Object.entries(value).filter(([, nestedValue]) => nestedValue !== undefined)
if (entries.length === 0) {
return <span className='text-[var(--text-muted)]'>None</span>
}
const hasComplexValues = entries.some(([, nestedValue]) => {
return Array.isArray(nestedValue) || isRecordLike(nestedValue)
})
if (!hasComplexValues) {
return (
<span className='text-[var(--text-primary)]'>
{entries
.map(([nestedKey, nestedValue]) => {
return `${formatMetadataLabel(nestedKey)}: ${formatPrimitiveValue((nestedValue as string | number | boolean | null) ?? null)}`
})
.join(' · ')}
</span>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
return (
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(value, null, 2)}
</pre>
)
}
function getMetadataEntries(metadata: unknown) {
if (!isRecordLike(metadata)) return []
return Object.entries(metadata).filter(([key, value]) => {
if (value === undefined) return false
return !['name', 'description'].includes(key)
})
}
interface ActionBadgeProps {
action: string
}
function ActionBadge({ action }: ActionBadgeProps) {
const [, verb] = action.split('.')
const variant =
verb === 'deleted' || verb === 'removed' || verb === 'revoked' ? 'red' : 'gray-secondary'
return (
<Badge variant={variant} size='sm' className='shrink-0'>
{formatAction(action)}
</Badge>
)
}
/** The expanded detail box content for one audit entry (resource, actor, metadata). */
function auditLogDetails(entry: EnterpriseAuditLogEntry): ReactNode {
const metadataEntries = getMetadataEntries(entry.metadata)
return (
<>
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Resource</span>
<span className='text-[var(--text-primary)]'>
{formatResourceType(entry.resourceType)}
{entry.resourceId && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.resourceId})</span>
)}
</span>
</div>
{entry.resourceName && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Name</span>
<span className='text-[var(--text-primary)]'>{entry.resourceName}</span>
</div>
)}
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Actor</span>
<span className='text-[var(--text-primary)]'>
{entry.actorName || 'Unknown'}
{entry.actorEmail && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.actorEmail})</span>
)}
</span>
</div>
{entry.description && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Description</span>
<span className='text-[var(--text-primary)]'>{entry.description}</span>
</div>
)}
{metadataEntries.map(([key, value]) => (
<div key={key} className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>
{formatMetadataLabel(key)}
</span>
<div className='min-w-0 flex-1'>{renderMetadataValue(value)}</div>
</div>
))}
</>
)
}
/** Maps an audit entry to the shared {@link ActivityLog} row shape. */
function toActivityEntry(entry: EnterpriseAuditLogEntry): ActivityLogEntry {
return {
id: entry.id,
timestamp: formatDateTime(new Date(entry.createdAt)),
event: <ActionBadge action={entry.action} />,
description: entry.description || entry.resourceName || entry.resourceId || '-',
actor: entry.actorEmail || entry.actorName || 'System',
details: auditLogDetails(entry),
}
}
export function AuditLogs() {
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
const [timeRange, setTimeRange] = useState<TimeRange>('Past 30 days')
const [customStartDate, setCustomStartDate] = useState('')
const [customEndDate, setCustomEndDate] = useState('')
const [datePickerOpen, setDatePickerOpen] = useState(false)
const previousTimeRangeRef = useRef<TimeRange>('Past 30 days')
const dateRangeAppliedRef = useRef(false)
const [searchTerm, setSearchTerm] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const refreshTimersRef = useRef(new Set<number>())
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
const trimmed = searchTerm.trim()
if (trimmed === debouncedSearch) return
debounceRef.current = setTimeout(() => {
setDebouncedSearch(trimmed)
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [searchTerm, debouncedSearch])
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
for (const timerId of timers) window.clearTimeout(timerId)
}
}, [])
const filters = useMemo<AuditLogFilters>(() => {
return {
search: debouncedSearch || undefined,
resourceType: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined,
startDate: getStartDateFromTimeRange(timeRange, customStartDate)?.toISOString(),
endDate: getEndDateFromTimeRange(timeRange, customEndDate)?.toISOString(),
}
}, [debouncedSearch, selectedTypes, timeRange, customStartDate, customEndDate])
const {
data,
isLoading,
isPlaceholderData,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
refetch,
} = useAuditLogs(filters)
const allEntries = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.data)
}, [data])
const typeDisplayLabel =
selectedTypes.length === 0
? 'All types'
: selectedTypes.length === 1
? RESOURCE_TYPE_OPTIONS.find((t) => t.value === selectedTypes[0])?.label || '1 selected'
: `${selectedTypes.length} types`
const timeDisplayLabel =
timeRange === 'Custom range' && customStartDate && customEndDate
? `${formatDateShort(customStartDate)} - ${formatDateShort(customEndDate)}`
: timeRange
const handleTimeRangeChange = (value: string) => {
if (value === 'Custom range') {
previousTimeRangeRef.current = timeRange
setDatePickerOpen(true)
} else {
setCustomStartDate('')
setCustomEndDate('')
setTimeRange(value as TimeRange)
}
}
const handleDateRangeApply = (start: string, end: string) => {
dateRangeAppliedRef.current = true
setCustomStartDate(start)
setCustomEndDate(end)
setTimeRange('Custom range')
setDatePickerOpen(false)
}
const handleDatePickerCancel = () => {
if (timeRange === 'Custom range' && !customStartDate) {
setTimeRange(previousTimeRangeRef.current)
}
setDatePickerOpen(false)
}
const handleRefresh = useCallback(() => {
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => {
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
refetch().catch((error: unknown) => {
logger.error('Failed to refresh audit logs', { error })
})
}, [refetch])
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage().catch((error: unknown) => {
logger.error('Failed to load more audit logs', { error })
})
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
const handleExportCsv = async () => {
setIsExporting(true)
try {
const params = new URLSearchParams()
if (filters.search) params.set('search', filters.search)
if (filters.resourceType) params.set('resourceType', filters.resourceType)
if (filters.startDate) params.set('startDate', filters.startDate)
if (filters.endDate) params.set('endDate', filters.endDate)
// boundary-raw-fetch: downloads a CSV blob and reads a response header before saving — a plain anchor navigation can't do either
const response = await fetch(`/api/audit-logs/export?${params.toString()}`)
if (!response.ok) {
toast.error('Failed to export audit logs')
return
}
if (response.headers.get('X-Export-Truncated') === '1') {
toast.info('Export truncated — narrow the date range to see everything')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} finally {
setIsExporting(false)
}
}
return (
<SettingsPanel
actions={[
{
text: 'Export',
icon: Download,
onSelect: () => void handleExportCsv(),
disabled: allEntries.length === 0 || isExporting || isPlaceholderData,
},
]}
>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
className='min-w-0 flex-1'
placeholder='Search audit logs...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ChipSelect
options={RESOURCE_TYPE_OPTIONS}
multiSelect
multiSelectValues={selectedTypes}
onMultiSelectChange={setSelectedTypes}
placeholder='All types'
displayLabel={typeDisplayLabel}
searchable
searchPlaceholder='Search types...'
showAllOption
allOptionLabel='All types'
align='start'
/>
<div className='relative'>
{/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix
DropdownMenu, modal by default) — a modal trigger closing in the
same tick that opens the Calendar popover below traps it behind
the modal's focus lock, so "Custom range" silently did nothing. */}
<ChipCombobox
options={TIME_RANGE_OPTIONS}
value={timeRange}
onChange={handleTimeRangeChange}
placeholder='All time'
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
}
maxHeight={320}
align='start'
/>
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
if (dateRangeAppliedRef.current) {
dateRangeAppliedRef.current = false
} else {
handleDatePickerCancel()
}
}
}}
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='start' sideOffset={4} className='w-auto p-0'>
<Calendar
mode='range'
showTime
startDate={customStartDate}
endDate={customEndDate}
onRangeChange={handleDateRangeApply}
onCancel={handleDatePickerCancel}
/>
</PopoverContent>
</Popover>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isVisuallyRefreshing}>
<RefreshCw animate={isVisuallyRefreshing} className='size-[14px]' />
</Button>
</div>
<ActivityLog
entries={allEntries.map(toActivityEntry)}
emptyState={
isLoading ? undefined : debouncedSearch ? (
<SettingsEmptyState variant='inline'>
No results for "{debouncedSearch}"
</SettingsEmptyState>
) : (
<SettingsEmptyState>No audit logs found</SettingsEmptyState>
)
}
footer={
hasNextPage ? (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
) : undefined
}
/>
</SettingsPanel>
)
}