mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(activity): three-source merged feed + detail drawer
This commit is contained in:
parent
170cb1f2f2
commit
b8ce36f6cd
@ -0,0 +1,166 @@
|
||||
package vip.mate.activity;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 §4.5 / §7 — unified Activity feed.
|
||||
*
|
||||
* <p>Merges three sources into one chronologically-ordered stream:
|
||||
* <ul>
|
||||
* <li>{@code audit_event} — CRUD-style events on agents / channels /
|
||||
* skills / wiki / workspace (the existing audit log)</li>
|
||||
* <li>{@code tool_approval} — approval requests + their resolution
|
||||
* (granted / denied / expired). Ties tool gating decisions
|
||||
* directly to the audit timeline.</li>
|
||||
* <li>Successful tool calls — RFC §4.5 mentions these, but the
|
||||
* runtime doesn't yet persist a row per successful call.
|
||||
* Returning an empty bucket keeps the API contract stable so
|
||||
* the UI can light up automatically once a future commit adds
|
||||
* persistence.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Pagination is best-effort: each source is paged from index 0
|
||||
* up to {@code size * 2}, then the merged list is trimmed and offset
|
||||
* in-memory. For workspaces with >>1k events / day a follow-up should
|
||||
* push merging into SQL; this is good enough for v1.
|
||||
*/
|
||||
@Tag(name = "Activity Feed (RFC-090)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/activity")
|
||||
@RequiredArgsConstructor
|
||||
public class ActivityFeedController {
|
||||
|
||||
private final AuditEventService auditEventService;
|
||||
private final ToolApprovalMapper toolApprovalMapper;
|
||||
|
||||
@Operation(summary = "Unified activity feed (audit + approval + tool calls)")
|
||||
@GetMapping("/feed")
|
||||
public R<Map<String, Object>> feed(
|
||||
@RequestParam(required = false) Long workspaceId,
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
if (size <= 0 || size > 200) size = 20;
|
||||
if (page <= 0) page = 1;
|
||||
|
||||
// Pull a chunk from each source large enough to cover the
|
||||
// requested page after merge. We read at most page*size rows
|
||||
// from each side; for stable cursors, future iterations should
|
||||
// push the merge into SQL with a UNION ALL view.
|
||||
int chunk = Math.max(size * page, 50);
|
||||
|
||||
List<ActivityRow> rows = new ArrayList<>();
|
||||
|
||||
boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source);
|
||||
boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source);
|
||||
|
||||
if (wantAudit) {
|
||||
try {
|
||||
IPage<AuditEventEntity> audit = auditEventService.listEvents(
|
||||
workspaceId, null, null, null, null, 1, chunk);
|
||||
for (AuditEventEntity ev : audit.getRecords()) {
|
||||
rows.add(fromAuditEvent(ev));
|
||||
}
|
||||
} catch (Exception ignored) { /* surface silence > one-source crash */ }
|
||||
}
|
||||
if (wantApproval) {
|
||||
try {
|
||||
Page<ToolApprovalEntity> p = new Page<>(1, chunk);
|
||||
LambdaQueryWrapper<ToolApprovalEntity> qw = new LambdaQueryWrapper<ToolApprovalEntity>()
|
||||
.orderByDesc(ToolApprovalEntity::getCreatedAt);
|
||||
IPage<ToolApprovalEntity> approvals = toolApprovalMapper.selectPage(p, qw);
|
||||
for (ToolApprovalEntity ap : approvals.getRecords()) {
|
||||
rows.add(fromApproval(ap));
|
||||
}
|
||||
} catch (Exception ignored) { /* see above */ }
|
||||
}
|
||||
|
||||
rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
long total = rows.size();
|
||||
int from = Math.min((page - 1) * size, rows.size());
|
||||
int to = Math.min(from + size, rows.size());
|
||||
List<ActivityRow> sliced = rows.subList(from, to);
|
||||
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("page", page);
|
||||
resp.put("size", size);
|
||||
resp.put("total", total);
|
||||
resp.put("records", sliced);
|
||||
return R.ok(resp);
|
||||
}
|
||||
|
||||
private ActivityRow fromAuditEvent(AuditEventEntity ev) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("detailJson", ev.getDetailJson());
|
||||
detail.put("userAgent", ev.getUserAgent());
|
||||
detail.put("workspaceId", ev.getWorkspaceId());
|
||||
return new ActivityRow(
|
||||
"audit-" + ev.getId(),
|
||||
"audit",
|
||||
ev.getCreateTime(),
|
||||
ev.getUsername(),
|
||||
ev.getAction(),
|
||||
ev.getResourceType(),
|
||||
ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(),
|
||||
ev.getIpAddress(),
|
||||
detail);
|
||||
}
|
||||
|
||||
private ActivityRow fromApproval(ToolApprovalEntity ap) {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("toolArguments", ap.getToolArguments());
|
||||
detail.put("summary", ap.getSummary());
|
||||
detail.put("maxSeverity", ap.getMaxSeverity());
|
||||
detail.put("status", ap.getStatus());
|
||||
detail.put("resolvedAt", ap.getResolvedAt());
|
||||
// Map approval status onto an audit-style action so the UI's
|
||||
// existing action coloring (CREATE / DELETE / etc.) keeps
|
||||
// working without a special case.
|
||||
String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase());
|
||||
return new ActivityRow(
|
||||
"approval-" + ap.getId(),
|
||||
"approval",
|
||||
ap.getCreatedAt(),
|
||||
ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(),
|
||||
action,
|
||||
"TOOL_APPROVAL",
|
||||
ap.getToolName(),
|
||||
null,
|
||||
detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format row. Public record so Jackson serializes it directly
|
||||
* without needing a separate DTO.
|
||||
*/
|
||||
public record ActivityRow(
|
||||
String id,
|
||||
String source,
|
||||
LocalDateTime time,
|
||||
String username,
|
||||
String action,
|
||||
String resourceType,
|
||||
String resourceName,
|
||||
String ipAddress,
|
||||
Map<String, Object> detail
|
||||
) {}
|
||||
}
|
||||
@ -177,6 +177,12 @@ export const skillApi = {
|
||||
employees: (id: string | number) => http.get(`/skills/${id}/employees`),
|
||||
}
|
||||
|
||||
// ==================== Activity Feed (RFC-090 §4.5) ====================
|
||||
export const activityApi = {
|
||||
feed: (params: { source?: string; page?: number; size?: number; workspaceId?: number } = {}) =>
|
||||
http.get('/activity/feed', { params }),
|
||||
}
|
||||
|
||||
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
|
||||
export const acpApi = {
|
||||
list: () => http.get('/acp/endpoints'),
|
||||
|
||||
@ -800,13 +800,19 @@ export default {
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity Log',
|
||||
desc: 'View all create, update, and delete operations on workspace resources.',
|
||||
desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).',
|
||||
allActions: 'All Actions',
|
||||
allResources: 'All Resources',
|
||||
allSources: 'All Sources',
|
||||
sourceAudit: 'Audit',
|
||||
sourceApproval: 'Approval',
|
||||
detailTitle: 'Event detail',
|
||||
detailDetails: 'Detail',
|
||||
loading: 'Loading...',
|
||||
noEvents: 'No activity yet',
|
||||
columns: {
|
||||
time: 'Time',
|
||||
source: 'Source',
|
||||
user: 'User',
|
||||
action: 'Action',
|
||||
resource: 'Resource',
|
||||
|
||||
@ -800,13 +800,19 @@ export default {
|
||||
},
|
||||
activity: {
|
||||
title: '操作日志',
|
||||
desc: '查看工作区内所有资源的创建、修改、删除操作记录。',
|
||||
desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。',
|
||||
allActions: '全部操作',
|
||||
allResources: '全部资源',
|
||||
allSources: '全部来源',
|
||||
sourceAudit: '审计',
|
||||
sourceApproval: '审批',
|
||||
detailTitle: '事件详情',
|
||||
detailDetails: '详情',
|
||||
loading: '加载中...',
|
||||
noEvents: '暂无操作记录',
|
||||
columns: {
|
||||
time: '时间',
|
||||
source: '来源',
|
||||
user: '用户',
|
||||
action: '操作',
|
||||
resource: '资源类型',
|
||||
|
||||
@ -15,15 +15,23 @@
|
||||
<div class="settings-section activity-inner mc-surface-card">
|
||||
<!-- Filters -->
|
||||
<div class="filter-row">
|
||||
<select v-model="filters.action" class="filter-select" @change="loadEvents">
|
||||
<!-- 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="loadEvents">
|
||||
<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>
|
||||
@ -31,6 +39,7 @@
|
||||
<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">
|
||||
@ -46,6 +55,7 @@
|
||||
<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>
|
||||
@ -54,10 +64,13 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in events" :key="event.id">
|
||||
<td class="cell-time">{{ formatTime(event.createTime) }}</td>
|
||||
<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="user-tag">{{ event.username }}</span>
|
||||
<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()">
|
||||
@ -76,6 +89,28 @@
|
||||
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- RFC-090 §4.5 detail drawer -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
:title="t('security.activity.detailTitle')"
|
||||
direction="rtl"
|
||||
size="520px"
|
||||
: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>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">«</button>
|
||||
@ -89,9 +124,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { auditApi } from '@/api'
|
||||
import { activityApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -100,14 +135,27 @@ const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const filters = reactive({ action: '', resourceType: '' })
|
||||
// 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 detailVisible = ref(false)
|
||||
const detailEvent = ref<any | null>(null)
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
return events.value.filter(ev => {
|
||||
if (filters.action && ev.action !== filters.action) return false
|
||||
if (filters.resourceType && ev.resourceType !== filters.resourceType) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function loadEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await auditApi.listEvents({
|
||||
action: filters.action || undefined,
|
||||
resourceType: filters.resourceType || undefined,
|
||||
const res: any = await activityApi.feed({
|
||||
source: filters.source || undefined,
|
||||
page: page.value,
|
||||
size: pageSize,
|
||||
})
|
||||
@ -120,6 +168,15 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
function filterEventsLocally() {
|
||||
// Local filter only — events array stays loaded; computed re-derives.
|
||||
}
|
||||
|
||||
function openDetail(event: any) {
|
||||
detailEvent.value = event
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
@ -204,4 +261,18 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
|
||||
/* 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; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user