feat(activity): redesign Activity page (taste pass)

This commit is contained in:
matevip 2026-05-01 09:50:39 +08:00
parent f8524bf122
commit 18c5c00797
3 changed files with 683 additions and 195 deletions

View File

@ -33,6 +33,8 @@ export default {
yes: 'Yes',
no: 'No',
configure: 'Configure',
refresh: 'Refresh',
collapse: 'Collapse',
enable: 'Enable',
disable: 'Disable',
},
@ -799,17 +801,51 @@ export default {
activity: 'Activity',
},
activity: {
title: 'Activity Log',
desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).',
title: 'Activity',
desc: 'Who did what, in plain English. Click any event to see the full story.',
allActions: 'All Actions',
allResources: 'All Resources',
allSources: 'All Sources',
allSources: 'All',
sourceAudit: 'Audit',
sourceApproval: 'Approval',
sourceApproval: 'Approvals',
detailTitle: 'Event detail',
detailDetails: 'Detail',
loading: 'Loading...',
loading: 'Loading',
noEvents: 'No activity yet',
emptyHint: 'As you create agents, bind skills, or approve tool calls, they show up here.',
moreFilters: 'More filters',
changes: 'Changes',
environment: 'Environment',
absoluteTime: 'Absolute time',
noValue: '(empty)',
rawPayload: 'Raw payload',
today: 'Today',
yesterday: 'Yesterday',
justNow: 'just now',
minutesAgo: '{n} min ago',
hoursAgo: '{n} h ago',
daysAgo: '{n} d ago',
verbs: {
create: 'created',
update: 'updated',
delete: 'deleted',
enable: 'enabled',
disable: 'disabled',
login: 'signed in',
logout: 'signed out',
approvalGranted: 'approved',
approvalDenied: 'denied',
approvalPending: 'requested',
},
resources: {
agent: 'agent',
channel: 'channel',
skill: 'skill',
wiki: 'wiki page',
member: 'member',
workspace: 'workspace',
toolApproval: 'tool call',
},
columns: {
time: 'Time',
source: 'Source',

View File

@ -35,6 +35,8 @@ export default {
configure: '配置',
enable: '启用',
disable: '停用',
refresh: '刷新',
collapse: '收起',
},
auth: {
changePassword: '修改密码',
@ -799,17 +801,51 @@ export default {
activity: '操作日志',
},
activity: {
title: '操作日志',
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。',
title: '活动',
desc: '谁做了什么,一句话讲清楚。点开任意一行看完整故事。',
allActions: '全部操作',
allResources: '全部资源',
allSources: '全部来源',
allSources: '全部',
sourceAudit: '审计',
sourceApproval: '审批',
detailTitle: '事件详情',
detailDetails: '详情',
loading: '加载中...',
loading: '加载中',
noEvents: '暂无操作记录',
emptyHint: '创建智能体、绑定技能、审批工具调用都会出现在这里。',
moreFilters: '更多筛选',
changes: '改动',
environment: '环境',
absoluteTime: '绝对时间',
noValue: '(空)',
rawPayload: '原始数据',
today: '今天',
yesterday: '昨天',
justNow: '刚刚',
minutesAgo: '{n} 分钟前',
hoursAgo: '{n} 小时前',
daysAgo: '{n} 天前',
verbs: {
create: '创建了',
update: '更新了',
delete: '删除了',
enable: '启用了',
disable: '停用了',
login: '登录了',
logout: '退出了',
approvalGranted: '批准了',
approvalDenied: '拒绝了',
approvalPending: '请求了',
},
resources: {
agent: '智能体',
channel: '频道',
skill: '技能',
wiki: 'Wiki 页',
member: '成员',
workspace: '工作区',
toolApproval: '工具调用',
},
columns: {
time: '时间',
source: '来源',

View File

@ -1,7 +1,7 @@
<template>
<!-- RFC-090 Phase 4: Activity 提升到顶层路由 /activity但本组件
既保留 settings-section 内层结构以便旧 Settings 引用兼容
又用 mc-page-shell 包一层在顶层路由下显示完整页框 -->
<!-- RFC-090 Phase 4 Jobs-style rewrite. Activity tells you who
did what, in plain English. The old 7-column table dumped raw
fields; the new layout reads like a feed. -->
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner activity-page">
@ -11,115 +11,187 @@
<h1 class="mc-page-title">{{ t('security.activity.title') }}</h1>
<p class="mc-page-desc">{{ t('security.activity.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-ghost" @click="loadEvents" :disabled="loading">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
{{ loading ? t('security.activity.loading') : t('common.refresh') }}
</button>
</div>
</div>
<div class="settings-section activity-inner mc-surface-card">
<!-- Filters -->
<div class="filter-row">
<!-- RFC-090 §4.5 source filter for the unified feed -->
<select v-model="filters.source" class="filter-select" @change="loadEvents">
<option value="">{{ t('security.activity.allSources') }}</option>
<option value="audit">{{ t('security.activity.sourceAudit') }}</option>
<option value="approval">{{ t('security.activity.sourceApproval') }}</option>
</select>
<select v-model="filters.action" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allActions') }}</option>
<option value="CREATE">CREATE</option>
<option value="UPDATE">UPDATE</option>
<option value="DELETE">DELETE</option>
<option value="ENABLE">ENABLE</option>
<option value="DISABLE">DISABLE</option>
<option value="APPROVAL_GRANTED">APPROVAL_GRANTED</option>
<option value="APPROVAL_DENIED">APPROVAL_DENIED</option>
</select>
<select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allResources') }}</option>
<option value="AGENT">Agent</option>
<option value="CHANNEL">Channel</option>
<option value="SKILL">Skill</option>
<option value="WIKI">Wiki</option>
<option value="MEMBER">Member</option>
<option value="WORKSPACE">Workspace</option>
<option value="TOOL_APPROVAL">Tool Approval</option>
</select>
<button class="btn-secondary" @click="loadEvents">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<!-- Filter chips minimal, source-only by default; extra
filters tucked behind a kebab so the page stays uncluttered -->
<div class="filter-chips">
<button
v-for="src in sourceChips"
:key="src.value"
class="filter-chip"
:class="{ active: filters.source === src.value }"
@click="setSourceFilter(src.value)"
>
<span v-if="src.dot" class="chip-dot" :class="`dot-${src.value || 'all'}`"></span>
{{ src.label }}
</button>
<div class="filter-spacer"></div>
<button class="filter-more" @click="showMoreFilters = !showMoreFilters">
{{ showMoreFilters ? t('common.collapse') : t('security.activity.moreFilters') }}
</button>
</div>
<transition name="fade">
<div v-if="showMoreFilters" class="filter-extra">
<select v-model="filters.action" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allActions') }}</option>
<option v-for="a in knownActions" :key="a" :value="a">{{ a }}</option>
</select>
<select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
<option value="">{{ t('security.activity.allResources') }}</option>
<option v-for="r in knownResources" :key="r" :value="r">{{ r }}</option>
</select>
</div>
</transition>
<!-- Event feed grouped by day, each event is one sentence -->
<div v-if="loading && groupedEvents.length === 0" class="feed-empty">
{{ t('security.activity.loading') }}
</div>
<div v-else-if="groupedEvents.length === 0" class="feed-empty">
<div class="empty-icon">📋</div>
<p>{{ t('security.activity.noEvents') }}</p>
<p class="empty-hint">{{ t('security.activity.emptyHint') }}</p>
</div>
<div v-else class="event-feed">
<template v-for="group in groupedEvents" :key="group.label">
<div class="day-divider">
<span class="day-label">{{ group.label }}</span>
<span class="day-count">{{ group.events.length }}</span>
</div>
<button
v-for="event in group.events"
:key="event.id"
class="event-row"
@click="openDetail(event)"
>
<span class="event-dot" :class="`dot-${event.source || 'audit'}`"></span>
<div class="event-body">
<div class="event-sentence">
<span class="event-actor">{{ event.username || '—' }}</span>
<span class="event-verb" :class="`verb-${(event.action || '').toLowerCase()}`">
{{ verbFor(event) }}
</span>
<span class="event-target">
<span class="target-type">{{ resourceLabel(event) }}</span>
<span v-if="event.resourceName || event.resourceId" class="target-name">
{{ event.resourceName || event.resourceId }}
</span>
</span>
</div>
<div v-if="event.summary" class="event-summary">{{ event.summary }}</div>
</div>
<span class="event-time" :title="absoluteTime(event)">{{ relativeTime(event) }}</span>
<span class="event-chevron"></span>
</button>
</template>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="pagination">
<button class="btn-ghost" :disabled="page <= 1" @click="page--; loadEvents()"></button>
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button class="btn-ghost" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()"></button>
</div>
</div>
</div>
<!-- Event Timeline -->
<div class="rules-table-wrapper">
<table class="rules-table">
<thead>
<tr>
<th>{{ t('security.activity.columns.time') }}</th>
<th>{{ t('security.activity.columns.source') }}</th>
<th>{{ t('security.activity.columns.user') }}</th>
<th>{{ t('security.activity.columns.action') }}</th>
<th>{{ t('security.activity.columns.resource') }}</th>
<th>{{ t('security.activity.columns.name') }}</th>
<th>{{ t('security.activity.columns.ip') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="event in filteredEvents" :key="event.id" class="data-row" @click="openDetail(event)">
<td class="cell-time">{{ formatTime(event.time || event.createTime) }}</td>
<td>
<span class="source-pill" :class="`src-${event.source || 'audit'}`">{{ event.source || 'audit' }}</span>
</td>
<td>
<span class="user-tag">{{ event.username || '—' }}</span>
</td>
<td>
<span class="action-tag" :class="'action-' + event.action?.toLowerCase()">
{{ event.action }}
</span>
</td>
<td>
<span class="resource-tag">{{ event.resourceType }}</span>
</td>
<td class="cell-name">{{ event.resourceName || event.resourceId || '-' }}</td>
<td class="cell-ip">{{ event.ipAddress || '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="empty-state">{{ t('security.activity.loading') }}</div>
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
</div>
<!-- RFC-090 §4.5 detail drawer -->
<!-- Detail drawer wider (720px), no internal max-height frame.
Hero block at the top so the user knows in 0.5s what happened.
JSON dump replaced by structured field rendering. -->
<el-drawer
v-model="detailVisible"
:title="t('security.activity.detailTitle')"
:show-close="true"
:with-header="false"
direction="rtl"
size="520px"
size="720px"
:destroy-on-close="true"
>
<div v-if="detailEvent" class="detail-pane">
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.time') }}</span><span>{{ formatTime(detailEvent.time || detailEvent.createTime) }}</span></div>
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.source') }}</span><span class="source-pill" :class="`src-${detailEvent.source || 'audit'}`">{{ detailEvent.source }}</span></div>
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.user') }}</span><span>{{ detailEvent.username || '—' }}</span></div>
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.action') }}</span><span class="action-tag" :class="'action-' + detailEvent.action?.toLowerCase()">{{ detailEvent.action }}</span></div>
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.resource') }}</span><span>{{ detailEvent.resourceType }}: <code>{{ detailEvent.resourceName || detailEvent.resourceId || '—' }}</code></span></div>
<div class="detail-row" v-if="detailEvent.ipAddress"><span class="detail-key">{{ t('security.activity.columns.ip') }}</span><code>{{ detailEvent.ipAddress }}</code></div>
<div class="detail-section" v-if="detailEvent.detail">
<div class="detail-section-title">{{ t('security.activity.detailDetails') }}</div>
<pre class="detail-pre">{{ JSON.stringify(detailEvent.detail, null, 2) }}</pre>
<div v-if="detailEvent" class="detail-shell">
<div class="detail-hero" :class="`hero-${detailEvent.source || 'audit'}`">
<span class="detail-dot" :class="`dot-${detailEvent.source || 'audit'}`"></span>
<h2 class="detail-headline">
<span class="detail-actor">{{ detailEvent.username || '—' }}</span>
<span class="detail-verb" :class="`verb-${(detailEvent.action || '').toLowerCase()}`">
{{ verbFor(detailEvent) }}
</span>
<span class="detail-target-type">{{ resourceLabel(detailEvent) }}</span>
</h2>
<div v-if="detailEvent.resourceName || detailEvent.resourceId" class="detail-target-name">
{{ detailEvent.resourceName || detailEvent.resourceId }}
</div>
<div class="detail-meta">
<span class="meta-time">{{ relativeTime(detailEvent) }}</span>
<span class="meta-sep">·</span>
<span class="meta-source-label">{{ sourceLabel(detailEvent) }}</span>
</div>
</div>
<!-- Changes block structured rendering of the detail map -->
<section v-if="changeEntries.length > 0" class="detail-block">
<h3 class="detail-block-title">{{ t('security.activity.changes') }}</h3>
<div class="change-list">
<div v-for="c in changeEntries" :key="c.key" class="change-item">
<span class="change-key">{{ c.key }}</span>
<span class="change-value" :class="{ 'is-empty': c.empty }">
<template v-if="c.empty">{{ t('security.activity.noValue') }}</template>
<template v-else-if="c.code"><code>{{ c.value }}</code></template>
<template v-else>{{ c.value }}</template>
</span>
</div>
</div>
</section>
<!-- Environment block IP, user-agent, absolute time -->
<section class="detail-block">
<h3 class="detail-block-title">{{ t('security.activity.environment') }}</h3>
<div class="change-list">
<div class="change-item">
<span class="change-key">{{ t('security.activity.absoluteTime') }}</span>
<span class="change-value"><code>{{ absoluteTime(detailEvent) }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.user') }}</span>
<span class="change-value">{{ detailEvent.username || '—' }}</span>
</div>
<div v-if="detailEvent.ipAddress" class="change-item">
<span class="change-key">{{ t('security.activity.columns.ip') }}</span>
<span class="change-value"><code>{{ detailEvent.ipAddress }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.action') }}</span>
<span class="change-value"><code>{{ detailEvent.action || '—' }}</code></span>
</div>
<div class="change-item">
<span class="change-key">{{ t('security.activity.columns.resource') }}</span>
<span class="change-value">
<code>{{ detailEvent.resourceType }}</code>
<span v-if="detailEvent.resourceName || detailEvent.resourceId">
· {{ detailEvent.resourceName || detailEvent.resourceId }}
</span>
</span>
</div>
</div>
</section>
<!-- Raw payload, collapsed power users only -->
<details v-if="rawPayload" class="detail-raw">
<summary>{{ t('security.activity.rawPayload') }}</summary>
<pre class="detail-pre">{{ rawPayload }}</pre>
</details>
</div>
</el-drawer>
<!-- Pagination -->
<div v-if="total > pageSize" class="pagination">
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">&laquo;</button>
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button class="btn-secondary btn-sm" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()">&raquo;</button>
</div>
</div>
</div>
</div>
</div>
</template>
@ -130,18 +202,46 @@ import { activityApi } from '@/api'
const { t } = useI18n()
const events = ref<any[]>([])
interface ActivityEvent {
id: string
source?: string
time?: string
createTime?: string
username?: string
action?: string
resourceType?: string
resourceName?: string
resourceId?: string
ipAddress?: string
summary?: string
detail?: Record<string, any>
}
const events = ref<ActivityEvent[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const pageSize = 30
const total = ref(0)
// RFC-090 §4.5 `source` filter goes to the server (audit / approval),
// `action` and `resourceType` filter locally so users can refine the
// merged feed without a roundtrip per change.
const filters = reactive({ source: '', action: '', resourceType: '' })
const showMoreFilters = ref(false)
const detailVisible = ref(false)
const detailEvent = ref<any | null>(null)
const detailEvent = ref<ActivityEvent | null>(null)
const sourceChips = computed(() => [
{ value: '', label: t('security.activity.allSources'), dot: false },
{ value: 'audit', label: t('security.activity.sourceAudit'), dot: true },
{ value: 'approval', label: t('security.activity.sourceApproval'), dot: true },
])
const knownActions = [
'CREATE', 'UPDATE', 'DELETE', 'ENABLE', 'DISABLE',
'LOGIN', 'LOGOUT',
'APPROVAL_GRANTED', 'APPROVAL_DENIED', 'APPROVAL_PENDING',
]
const knownResources = [
'AGENT', 'CHANNEL', 'SKILL', 'WIKI', 'MEMBER', 'WORKSPACE', 'TOOL_APPROVAL',
]
const filteredEvents = computed(() => {
return events.value.filter(ev => {
@ -151,6 +251,103 @@ const filteredEvents = computed(() => {
})
})
/**
* RFC-090 §4.5 group events by relative day so the eye gets a
* rhythm (Today / Yesterday / weekday) instead of a flat 30-row
* timestamp soup.
*/
const groupedEvents = computed(() => {
const groups: { label: string; events: ActivityEvent[] }[] = []
const today = startOfDay(new Date())
const yesterday = new Date(today.getTime() - 86_400_000)
const weekAgo = new Date(today.getTime() - 7 * 86_400_000)
function bucket(d: Date): string {
const day = startOfDay(d)
if (day.getTime() === today.getTime()) return t('security.activity.today')
if (day.getTime() === yesterday.getTime()) return t('security.activity.yesterday')
if (day.getTime() > weekAgo.getTime()) {
return d.toLocaleDateString(undefined, { weekday: 'long' })
}
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
let lastLabel = ''
for (const ev of filteredEvents.value) {
const ts = ev.time || ev.createTime
if (!ts) continue
const d = new Date(ts)
const label = bucket(d)
if (label !== lastLabel) {
groups.push({ label, events: [] })
lastLabel = label
}
groups[groups.length - 1].events.push(ev)
}
return groups
})
/** Decompose detail map into structured [{key, value, code, empty}] rows
* while filtering out fields that are already in the hero block (action,
* resource, time) and string-encoded JSON noise from the audit pipeline. */
const changeEntries = computed<Array<{ key: string; value: string; code: boolean; empty: boolean }>>(() => {
const ev = detailEvent.value
if (!ev || !ev.detail) return []
const skipKeys = new Set([
'workspaceId', 'userAgent', 'detailJson',
'time', 'createTime', 'action', 'resourceType', 'resourceName', 'resourceId',
'username', 'ipAddress',
])
const out: Array<{ key: string; value: string; code: boolean; empty: boolean }> = []
// The audit pipeline historically nests JSON inside a string-typed
// `detailJson` field. Try to expand it so the change list stays
// first-class structured, not "open the raw payload to read".
const merged: Record<string, any> = { ...ev.detail }
const nested = ev.detail.detailJson
if (typeof nested === 'string' && nested.trim().startsWith('{')) {
try {
const parsed = JSON.parse(nested)
Object.assign(merged, parsed)
} catch { /* leave detailJson as-is */ }
}
for (const [key, raw] of Object.entries(merged)) {
if (skipKeys.has(key)) continue
if (raw === undefined || raw === null || raw === '') {
out.push({ key, value: '', code: false, empty: true })
continue
}
if (typeof raw === 'object') {
out.push({ key, value: JSON.stringify(raw), code: true, empty: false })
} else {
const s = String(raw)
const code = s.length < 80 && (s.startsWith('{') || s.startsWith('[') || /^[\w./:_-]+$/.test(s))
out.push({ key, value: s, code, empty: false })
}
}
return out
})
/** Power-user payload (only when there's actually a detail map). */
const rawPayload = computed(() => {
const ev = detailEvent.value
if (!ev || !ev.detail) return ''
return JSON.stringify(ev.detail, null, 2)
})
function startOfDay(d: Date): Date {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
function setSourceFilter(value: string) {
filters.source = value
page.value = 1
loadEvents()
}
async function loadEvents() {
loading.value = true
try {
@ -168,19 +365,72 @@ async function loadEvents() {
}
}
function filterEventsLocally() {
// Local filter only events array stays loaded; computed re-derives.
}
function filterEventsLocally() { /* trigger recompute via reactive filter */ }
function openDetail(event: any) {
function openDetail(event: ActivityEvent) {
detailEvent.value = event
detailVisible.value = true
}
function formatTime(dateStr: string) {
if (!dateStr) return '-'
const d = new Date(dateStr)
return d.toLocaleString()
/** Natural-language verb. Audit actions get a clean past-tense translation;
* approval actions get domain-appropriate verbs. Falls back to the raw
* action so future enums don't silently render as "—". */
function verbFor(event: ActivityEvent): string {
const a = (event.action || '').toUpperCase()
const map: Record<string, string> = {
CREATE: t('security.activity.verbs.create'),
UPDATE: t('security.activity.verbs.update'),
DELETE: t('security.activity.verbs.delete'),
ENABLE: t('security.activity.verbs.enable'),
DISABLE: t('security.activity.verbs.disable'),
LOGIN: t('security.activity.verbs.login'),
LOGOUT: t('security.activity.verbs.logout'),
APPROVAL_GRANTED: t('security.activity.verbs.approvalGranted'),
APPROVAL_DENIED: t('security.activity.verbs.approvalDenied'),
APPROVAL_PENDING: t('security.activity.verbs.approvalPending'),
}
return map[a] || (event.action || '—')
}
function resourceLabel(event: ActivityEvent): string {
const r = (event.resourceType || '').toUpperCase()
const map: Record<string, string> = {
AGENT: t('security.activity.resources.agent'),
CHANNEL: t('security.activity.resources.channel'),
SKILL: t('security.activity.resources.skill'),
WIKI: t('security.activity.resources.wiki'),
MEMBER: t('security.activity.resources.member'),
WORKSPACE: t('security.activity.resources.workspace'),
TOOL_APPROVAL: t('security.activity.resources.toolApproval'),
}
return map[r] || (event.resourceType || '')
}
function sourceLabel(event: ActivityEvent): string {
if (event.source === 'approval') return t('security.activity.sourceApproval')
if (event.source === 'audit') return t('security.activity.sourceAudit')
return event.source || ''
}
function relativeTime(event: ActivityEvent): string {
const ts = event.time || event.createTime
if (!ts) return '—'
const now = Date.now()
const then = new Date(ts).getTime()
const diffSec = Math.max(0, Math.floor((now - then) / 1000))
if (diffSec < 60) return t('security.activity.justNow')
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return t('security.activity.minutesAgo', { n: diffMin })
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return t('security.activity.hoursAgo', { n: diffHr })
const diffDay = Math.floor(diffHr / 24)
if (diffDay < 7) return t('security.activity.daysAgo', { n: diffDay })
return new Date(ts).toLocaleDateString()
}
function absoluteTime(event: ActivityEvent): string {
const ts = event.time || event.createTime
return ts ? new Date(ts).toLocaleString() : '—'
}
onMounted(() => {
@ -188,91 +438,257 @@ onMounted(() => {
})
</script>
<style>
@import '../shared.css';
</style>
<style scoped>
/* RFC-090 Phase 4 — top-level standalone shell */
.activity-page { gap: 18px; }
.activity-inner { padding: 18px; }
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
.activity-page { gap: 16px; display: flex; flex-direction: column; padding: 0; }
.header-actions { display: flex; gap: 8px; align-items: center; }
.btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border: 1px solid var(--mc-border-light);
background: transparent; color: var(--mc-text-secondary);
border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
transition: all 0.15s;
}
.btn-ghost:hover:not(:disabled) { background: var(--mc-bg-muted); color: var(--mc-text-primary); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
/* Filter chips — minimal source toggle, one row */
.filter-chips {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 4px 0;
}
.filter-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated); color: var(--mc-text-secondary);
border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
transition: all 0.15s;
}
.filter-chip:hover { border-color: var(--mc-border); color: var(--mc-text-primary); }
.filter-chip.active {
background: var(--mc-text-primary); color: var(--mc-bg-elevated);
border-color: var(--mc-text-primary); font-weight: 600;
}
.chip-dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-all { background: var(--mc-text-tertiary); }
.dot-audit { background: #f59e0b; }
.dot-approval { background: #6366f1; }
.dot-tool { background: #10b981; }
.filter-spacer { flex: 1; }
.filter-more {
background: none; border: none; color: var(--mc-text-tertiary);
font-size: 12px; cursor: pointer; padding: 4px 8px;
}
.filter-more:hover { color: var(--mc-text-primary); }
.filter-extra { display: flex; gap: 8px; padding: 4px 0; }
.filter-select {
padding: 6px 12px;
border: 1px solid var(--mc-border);
border-radius: 6px;
background: var(--mc-bg);
color: var(--mc-text-primary);
font-size: 13px;
padding: 6px 10px; border: 1px solid var(--mc-border-light);
border-radius: 8px; background: var(--mc-bg-elevated);
color: var(--mc-text-primary); font-size: 12px;
}
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
.cell-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-ip { font-size: 12px; color: var(--mc-text-tertiary); font-family: 'SF Mono', monospace; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s, max-height 0.2s; max-height: 60px; }
.fade-enter-from, .fade-leave-to { opacity: 0; max-height: 0; }
.user-tag {
padding: 2px 8px;
border-radius: 4px;
/* Event feed — flat cards, day grouped */
.event-feed { display: flex; flex-direction: column; }
.day-divider {
display: flex; align-items: center; gap: 10px;
padding: 14px 4px 6px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
}
.day-label::after { content: ''; }
.day-count {
padding: 1px 8px; border-radius: 999px;
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary);
font-size: 10px; font-weight: 600; letter-spacing: 0;
}
.event-row {
width: 100%;
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px;
border: 1px solid var(--mc-border-light);
border-radius: 12px;
background: var(--mc-bg-elevated);
cursor: pointer;
text-align: left;
transition: all 0.15s;
margin-bottom: 6px;
font: inherit;
color: inherit;
}
.event-row:hover {
border-color: var(--mc-border);
background: var(--mc-bg-muted);
transform: translateY(-1px);
box-shadow: var(--mc-shadow-soft);
}
.event-dot {
width: 8px; height: 8px; border-radius: 50%;
margin-top: 7px; flex-shrink: 0;
}
.event-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.event-sentence {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
font-size: 14px; line-height: 1.5;
color: var(--mc-text-primary);
}
.event-actor { font-weight: 600; color: var(--mc-text-primary); }
.event-verb {
font-weight: 500; padding: 1px 8px; border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.action-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
.event-target { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.target-type { color: var(--mc-text-tertiary); font-size: 13px; }
.target-name {
color: var(--mc-text-primary); font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
overflow: hidden; text-overflow: ellipsis; max-width: 320px; white-space: nowrap;
}
.action-create { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.action-update { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.action-delete { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.action-enable { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.action-disable { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
.action-login { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.action-logout { background: rgba(107, 114, 128, 0.12); color: #6b7280; }
.resource-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
font-family: 'SF Mono', 'Fira Code', monospace;
.event-summary {
font-size: 12px; color: var(--mc-text-tertiary);
line-height: 1.5;
}
.event-time {
font-size: 12px; color: var(--mc-text-tertiary);
white-space: nowrap; flex-shrink: 0; padding-top: 2px;
}
.event-chevron {
color: var(--mc-text-tertiary); font-size: 18px; line-height: 1;
padding-top: 0; flex-shrink: 0; opacity: 0;
transition: opacity 0.15s, transform 0.15s;
}
.event-row:hover .event-chevron { opacity: 1; transform: translateX(2px); }
/* Verb tinting — green = create/grant, red = delete/deny, etc. */
.verb-create, .verb-enable, .verb-approval_granted { color: #16a34a; background: rgba(34, 197, 94, 0.10); }
.verb-update { color: #2563eb; background: rgba(59, 130, 246, 0.10); }
.verb-delete, .verb-approval_denied { color: #dc2626; background: rgba(239, 68, 68, 0.10); }
.verb-disable, .verb-approval_pending { color: #d97706; background: rgba(245, 158, 11, 0.10); }
.verb-login, .verb-logout { color: #7c3aed; background: rgba(139, 92, 246, 0.10); }
:root.dark .verb-create, :root.dark .verb-enable, :root.dark .verb-approval_granted { color: #4ade80; background: rgba(34, 197, 94, 0.18); }
:root.dark .verb-update { color: #60a5fa; background: rgba(59, 130, 246, 0.18); }
:root.dark .verb-delete, :root.dark .verb-approval_denied { color: #fca5a5; background: rgba(239, 68, 68, 0.18); }
:root.dark .verb-disable, :root.dark .verb-approval_pending { color: #fbbf24; background: rgba(245, 158, 11, 0.18); }
:root.dark .verb-login, :root.dark .verb-logout { color: #c4b5fd; background: rgba(139, 92, 246, 0.18); }
.feed-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 60px 20px;
color: var(--mc-text-tertiary); font-size: 14px;
text-align: center;
}
.empty-icon { font-size: 32px; }
.empty-hint { font-size: 12px; max-width: 320px; line-height: 1.6; }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
display: flex; align-items: center; justify-content: center;
gap: 14px; margin-top: 18px;
}
.page-info { font-size: 12px; color: var(--mc-text-tertiary); }
/* Detail drawer — wider, no internal max-height frame */
.detail-shell {
padding: 24px 28px 32px;
display: flex; flex-direction: column; gap: 22px;
}
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
.detail-hero {
position: relative;
padding: 4px 0 18px;
border-bottom: 1px solid var(--mc-border-light);
}
.detail-dot {
position: absolute; left: -16px; top: 16px;
width: 10px; height: 10px; border-radius: 50%;
}
.detail-headline {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 0 0 6px;
font-size: 22px; font-weight: 700; line-height: 1.3;
color: var(--mc-text-primary);
letter-spacing: -0.01em;
}
.detail-actor { font-weight: 700; }
.detail-verb {
font-size: 16px; font-weight: 600;
padding: 2px 10px; border-radius: 8px;
}
.detail-target-type { color: var(--mc-text-secondary); font-weight: 500; font-size: 18px; }
.detail-target-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 15px; color: var(--mc-text-primary);
background: var(--mc-bg-sunken);
padding: 4px 10px; border-radius: 6px;
display: inline-block; margin-top: 4px;
word-break: break-all;
}
.detail-meta {
display: flex; align-items: center; gap: 8px;
margin-top: 10px;
font-size: 12px; color: var(--mc-text-tertiary);
}
.meta-sep { opacity: 0.5; }
/* RFC-090 §4.5 — source pill + clickable rows + detail drawer */
.source-pill { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.src-audit { background: var(--mc-primary-bg); color: var(--mc-primary); }
.src-approval { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.data-row { cursor: pointer; }
.data-row:hover { background: var(--mc-bg-muted); }
.detail-pane { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; }
.detail-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--mc-border-light); font-size: 13px; }
.detail-row:last-child { border-bottom: none; }
.detail-key { width: 110px; flex-shrink: 0; color: var(--mc-text-tertiary); font-size: 12px; font-weight: 600; }
.detail-section { margin-top: 6px; }
.detail-section-title { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.detail-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 11px; line-height: 1.5; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
.detail-block { display: flex; flex-direction: column; gap: 10px; }
.detail-block-title {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
margin: 0;
}
.change-list { display: flex; flex-direction: column; gap: 0; border-top: 1px solid var(--mc-border-light); }
.change-item {
display: flex; align-items: flex-start; gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--mc-border-light);
font-size: 13px;
line-height: 1.5;
}
.change-item:last-child { border-bottom: none; }
.change-key {
width: 140px; flex-shrink: 0;
color: var(--mc-text-tertiary); font-size: 12px; font-weight: 600;
}
.change-value {
flex: 1; min-width: 0;
color: var(--mc-text-primary);
word-break: break-word;
}
.change-value code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
background: var(--mc-bg-sunken);
padding: 2px 6px; border-radius: 4px;
}
.change-value.is-empty { color: var(--mc-text-tertiary); font-style: italic; }
.detail-raw {
margin-top: 4px;
border-top: 1px solid var(--mc-border-light);
padding-top: 14px;
}
.detail-raw summary {
cursor: pointer; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
padding: 4px 0;
}
.detail-raw summary:hover { color: var(--mc-text-secondary); }
.detail-pre {
margin-top: 8px;
background: var(--mc-bg-sunken);
padding: 14px; border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; line-height: 1.6;
color: var(--mc-text-secondary);
white-space: pre-wrap; word-break: break-word;
}
</style>