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', yes: 'Yes',
no: 'No', no: 'No',
configure: 'Configure', configure: 'Configure',
refresh: 'Refresh',
collapse: 'Collapse',
enable: 'Enable', enable: 'Enable',
disable: 'Disable', disable: 'Disable',
}, },
@ -799,17 +801,51 @@ export default {
activity: 'Activity', activity: 'Activity',
}, },
activity: { activity: {
title: 'Activity Log', title: 'Activity',
desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).', desc: 'Who did what, in plain English. Click any event to see the full story.',
allActions: 'All Actions', allActions: 'All Actions',
allResources: 'All Resources', allResources: 'All Resources',
allSources: 'All Sources', allSources: 'All',
sourceAudit: 'Audit', sourceAudit: 'Audit',
sourceApproval: 'Approval', sourceApproval: 'Approvals',
detailTitle: 'Event detail', detailTitle: 'Event detail',
detailDetails: 'Detail', detailDetails: 'Detail',
loading: 'Loading...', loading: 'Loading',
noEvents: 'No activity yet', 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: { columns: {
time: 'Time', time: 'Time',
source: 'Source', source: 'Source',

View File

@ -35,6 +35,8 @@ export default {
configure: '配置', configure: '配置',
enable: '启用', enable: '启用',
disable: '停用', disable: '停用',
refresh: '刷新',
collapse: '收起',
}, },
auth: { auth: {
changePassword: '修改密码', changePassword: '修改密码',
@ -799,17 +801,51 @@ export default {
activity: '操作日志', activity: '操作日志',
}, },
activity: { activity: {
title: '操作日志', title: '活动',
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。', desc: '谁做了什么,一句话讲清楚。点开任意一行看完整故事。',
allActions: '全部操作', allActions: '全部操作',
allResources: '全部资源', allResources: '全部资源',
allSources: '全部来源', allSources: '全部',
sourceAudit: '审计', sourceAudit: '审计',
sourceApproval: '审批', sourceApproval: '审批',
detailTitle: '事件详情', detailTitle: '事件详情',
detailDetails: '详情', detailDetails: '详情',
loading: '加载中...', loading: '加载中',
noEvents: '暂无操作记录', 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: { columns: {
time: '时间', time: '时间',
source: '来源', source: '来源',

View File

@ -1,7 +1,7 @@
<template> <template>
<!-- RFC-090 Phase 4: Activity 提升到顶层路由 /activity但本组件 <!-- RFC-090 Phase 4 Jobs-style rewrite. Activity tells you who
既保留 settings-section 内层结构以便旧 Settings 引用兼容 did what, in plain English. The old 7-column table dumped raw
又用 mc-page-shell 包一层在顶层路由下显示完整页框 --> fields; the new layout reads like a feed. -->
<div class="mc-page-shell"> <div class="mc-page-shell">
<div class="mc-page-frame"> <div class="mc-page-frame">
<div class="mc-page-inner activity-page"> <div class="mc-page-inner activity-page">
@ -11,115 +11,187 @@
<h1 class="mc-page-title">{{ t('security.activity.title') }}</h1> <h1 class="mc-page-title">{{ t('security.activity.title') }}</h1>
<p class="mc-page-desc">{{ t('security.activity.desc') }}</p> <p class="mc-page-desc">{{ t('security.activity.desc') }}</p>
</div> </div>
</div> <div class="header-actions">
<div class="settings-section activity-inner mc-surface-card"> <button class="btn-ghost" @click="loadEvents" :disabled="loading">
<!-- 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"> <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"/> <polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg> </svg>
{{ loading ? t('security.activity.loading') : t('common.refresh') }}
</button>
</div>
</div>
<!-- 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> </button>
</div> </div>
<!-- Event Timeline --> <transition name="fade">
<div class="rules-table-wrapper"> <div v-if="showMoreFilters" class="filter-extra">
<table class="rules-table"> <select v-model="filters.action" class="filter-select" @change="filterEventsLocally">
<thead> <option value="">{{ t('security.activity.allActions') }}</option>
<tr> <option v-for="a in knownActions" :key="a" :value="a">{{ a }}</option>
<th>{{ t('security.activity.columns.time') }}</th> </select>
<th>{{ t('security.activity.columns.source') }}</th> <select v-model="filters.resourceType" class="filter-select" @change="filterEventsLocally">
<th>{{ t('security.activity.columns.user') }}</th> <option value="">{{ t('security.activity.allResources') }}</option>
<th>{{ t('security.activity.columns.action') }}</th> <option v-for="r in knownResources" :key="r" :value="r">{{ r }}</option>
<th>{{ t('security.activity.columns.resource') }}</th> </select>
<th>{{ t('security.activity.columns.name') }}</th> </div>
<th>{{ t('security.activity.columns.ip') }}</th> </transition>
</tr>
</thead> <!-- Event feed grouped by day, each event is one sentence -->
<tbody> <div v-if="loading && groupedEvents.length === 0" class="feed-empty">
<tr v-for="event in filteredEvents" :key="event.id" class="data-row" @click="openDetail(event)"> {{ t('security.activity.loading') }}
<td class="cell-time">{{ formatTime(event.time || event.createTime) }}</td> </div>
<td> <div v-else-if="groupedEvents.length === 0" class="feed-empty">
<span class="source-pill" :class="`src-${event.source || 'audit'}`">{{ event.source || 'audit' }}</span> <div class="empty-icon">📋</div>
</td> <p>{{ t('security.activity.noEvents') }}</p>
<td> <p class="empty-hint">{{ t('security.activity.emptyHint') }}</p>
<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> </div>
<!-- RFC-090 §4.5 detail drawer --> <div v-else class="event-feed">
<el-drawer <template v-for="group in groupedEvents" :key="group.label">
v-model="detailVisible" <div class="day-divider">
:title="t('security.activity.detailTitle')" <span class="day-label">{{ group.label }}</span>
direction="rtl" <span class="day-count">{{ group.events.length }}</span>
size="520px" </div>
:destroy-on-close="true" <button
v-for="event in group.events"
:key="event.id"
class="event-row"
@click="openDetail(event)"
> >
<div v-if="detailEvent" class="detail-pane"> <span class="event-dot" :class="`dot-${event.source || 'audit'}`"></span>
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.time') }}</span><span>{{ formatTime(detailEvent.time || detailEvent.createTime) }}</span></div> <div class="event-body">
<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="event-sentence">
<div class="detail-row"><span class="detail-key">{{ t('security.activity.columns.user') }}</span><span>{{ detailEvent.username || '—' }}</span></div> <span class="event-actor">{{ event.username || '—' }}</span>
<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> <span class="event-verb" :class="`verb-${(event.action || '').toLowerCase()}`">
<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> {{ verbFor(event) }}
<div class="detail-row" v-if="detailEvent.ipAddress"><span class="detail-key">{{ t('security.activity.columns.ip') }}</span><code>{{ detailEvent.ipAddress }}</code></div> </span>
<div class="detail-section" v-if="detailEvent.detail"> <span class="event-target">
<div class="detail-section-title">{{ t('security.activity.detailDetails') }}</div> <span class="target-type">{{ resourceLabel(event) }}</span>
<pre class="detail-pre">{{ JSON.stringify(detailEvent.detail, null, 2) }}</pre> <span v-if="event.resourceName || event.resourceId" class="target-name">
{{ event.resourceName || event.resourceId }}
</span>
</span>
</div> </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> </div>
</el-drawer>
<!-- Pagination --> <!-- Pagination -->
<div v-if="total > pageSize" class="pagination"> <div v-if="total > pageSize" class="pagination">
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">&laquo;</button> <button class="btn-ghost" :disabled="page <= 1" @click="page--; loadEvents()"></button>
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span> <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> <button class="btn-ghost" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()"></button>
</div> </div>
</div> </div>
</div> </div>
<!-- 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"
:show-close="true"
:with-header="false"
direction="rtl"
size="720px"
:destroy-on-close="true"
>
<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>
<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>
</div> </div>
</template> </template>
@ -130,18 +202,46 @@ import { activityApi } from '@/api'
const { t } = useI18n() 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 loading = ref(false)
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 30
const total = ref(0) 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 filters = reactive({ source: '', action: '', resourceType: '' })
const showMoreFilters = ref(false)
const detailVisible = 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(() => { const filteredEvents = computed(() => {
return events.value.filter(ev => { 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() { async function loadEvents() {
loading.value = true loading.value = true
try { try {
@ -168,19 +365,72 @@ async function loadEvents() {
} }
} }
function filterEventsLocally() { function filterEventsLocally() { /* trigger recompute via reactive filter */ }
// Local filter only events array stays loaded; computed re-derives.
}
function openDetail(event: any) { function openDetail(event: ActivityEvent) {
detailEvent.value = event detailEvent.value = event
detailVisible.value = true detailVisible.value = true
} }
function formatTime(dateStr: string) { /** Natural-language verb. Audit actions get a clean past-tense translation;
if (!dateStr) return '-' * approval actions get domain-appropriate verbs. Falls back to the raw
const d = new Date(dateStr) * action so future enums don't silently render as "—". */
return d.toLocaleString() 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(() => { onMounted(() => {
@ -188,91 +438,257 @@ onMounted(() => {
}) })
</script> </script>
<style>
@import '../shared.css';
</style>
<style scoped> <style scoped>
/* RFC-090 Phase 4 — top-level standalone shell */ .activity-page { gap: 16px; display: flex; flex-direction: column; padding: 0; }
.activity-page { gap: 18px; } .header-actions { display: flex; gap: 8px; align-items: center; }
.activity-inner { padding: 18px; } .btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
.filter-row { padding: 6px 12px; border: 1px solid var(--mc-border-light);
display: flex; background: transparent; color: var(--mc-text-secondary);
gap: 8px; border-radius: 999px; font-size: 12px; font-weight: 500; cursor: pointer;
margin-bottom: 16px; 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 { .filter-select {
padding: 6px 12px; padding: 6px 10px; border: 1px solid var(--mc-border-light);
border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated);
border-radius: 6px; color: var(--mc-text-primary); font-size: 12px;
background: var(--mc-bg);
color: var(--mc-text-primary);
font-size: 13px;
} }
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; } .fade-enter-active, .fade-leave-active { transition: opacity 0.15s, max-height 0.2s; max-height: 60px; }
.cell-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fade-enter-from, .fade-leave-to { opacity: 0; max-height: 0; }
.cell-ip { font-size: 12px; color: var(--mc-text-tertiary); font-family: 'SF Mono', monospace; }
.user-tag { /* Event feed — flat cards, day grouped */
padding: 2px 8px; .event-feed { display: flex; flex-direction: column; }
border-radius: 4px;
.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-size: 12px;
font-weight: 500;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
} }
.event-target { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.action-tag { .target-type { color: var(--mc-text-tertiary); font-size: 13px; }
display: inline-block; .target-name {
padding: 2px 8px; color: var(--mc-text-primary); font-weight: 500;
border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; font-size: 13px;
font-weight: 600; overflow: hidden; text-overflow: ellipsis; max-width: 320px; white-space: nowrap;
text-transform: uppercase;
} }
.event-summary {
.action-create { background: rgba(16, 185, 129, 0.12); color: #10b981; } font-size: 12px; color: var(--mc-text-tertiary);
.action-update { background: rgba(59, 130, 246, 0.12); color: #3b82f6; } line-height: 1.5;
.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-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 { .pagination {
display: flex; display: flex; align-items: center; justify-content: center;
align-items: center; gap: 14px; margin-top: 18px;
justify-content: center; }
gap: 12px; .page-info { font-size: 12px; color: var(--mc-text-tertiary); }
margin-top: 16px;
/* 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 */ .detail-block { display: flex; flex-direction: column; gap: 10px; }
.source-pill { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } .detail-block-title {
.src-audit { background: var(--mc-primary-bg); color: var(--mc-primary); } font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
.src-approval { background: rgba(99, 102, 241, 0.12); color: #6366f1; } color: var(--mc-text-tertiary);
.data-row { cursor: pointer; } margin: 0;
.data-row:hover { background: var(--mc-bg-muted); } }
.detail-pane { padding: 12px 16px; display: flex; flex-direction: column; gap: 10px; } .change-list { display: flex; flex-direction: column; gap: 0; border-top: 1px solid var(--mc-border-light); }
.detail-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--mc-border-light); font-size: 13px; } .change-item {
.detail-row:last-child { border-bottom: none; } display: flex; align-items: flex-start; gap: 16px;
.detail-key { width: 110px; flex-shrink: 0; color: var(--mc-text-tertiary); font-size: 12px; font-weight: 600; } padding: 10px 0;
.detail-section { margin-top: 6px; } border-bottom: 1px solid var(--mc-border-light);
.detail-section-title { font-size: 12px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; } font-size: 13px;
.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; } 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> </style>