diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index bb4a4e3a..f15cc732 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 7282b647..d8b80fd0 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '来源', diff --git a/mateclaw-ui/src/views/Security/Activity/index.vue b/mateclaw-ui/src/views/Security/Activity/index.vue index 3c81b767..9e136780 100644 --- a/mateclaw-ui/src/views/Security/Activity/index.vue +++ b/mateclaw-ui/src/views/Security/Activity/index.vue @@ -1,7 +1,7 @@ @@ -130,18 +202,46 @@ import { activityApi } from '@/api' const { t } = useI18n() -const events = ref([]) +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 +} + +const events = ref([]) 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(null) +const detailEvent = ref(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>(() => { + 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 = { ...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 = { + 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 = { + 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(() => { }) - -