diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java index 343b5d62..839143ca 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java @@ -525,6 +525,90 @@ public class ApprovalWorkflowService implements ApplicationRunner { } } + // ==================== Global pending query (admin/notification surface) ==================== + + /** + * Default cap for {@link #listPendingFromDb(int)} when callers don't specify + * one, so a runaway pending table cannot drown the notification panel. + */ + public static final int DEFAULT_PENDING_LIST_LIMIT = 200; + + /** + * Hard ceiling regardless of caller-requested limit. + */ + public static final int MAX_PENDING_LIST_LIMIT = 500; + + /** + * Return every {@code PENDING} approval row, newest first, capped at {@code limit}. + * Reads from {@code mate_tool_approval} directly so restart / recovery edge + * cases cannot leave the in-memory map and the DB out of sync from a caller's + * perspective. + * + *

Payload shape matches {@link ApprovalService#getPendingByConversation}, + * so the same frontend renderer can consume both surfaces. + */ + public List> listPendingFromDb(int limit) { + int effectiveLimit = limit <= 0 ? DEFAULT_PENDING_LIST_LIMIT + : Math.min(limit, MAX_PENDING_LIST_LIMIT); + List rows; + try { + rows = approvalMapper.selectList( + new LambdaQueryWrapper() + .eq(ToolApprovalEntity::getStatus, "PENDING") + .orderByDesc(ToolApprovalEntity::getCreatedAt) + .last("LIMIT " + effectiveLimit) + ); + } catch (Exception e) { + log.warn("[ApprovalWorkflow] listPendingFromDb failed: {}", e.getMessage()); + return List.of(); + } + return rows.stream().map(this::toPendingPayload).toList(); + } + + /** + * Count of pending approvals in {@code mate_tool_approval}. Used by the + * notification summary endpoint; cheap enough to call on every poll. + */ + public long countPendingFromDb() { + try { + Long n = approvalMapper.selectCount( + new LambdaQueryWrapper() + .eq(ToolApprovalEntity::getStatus, "PENDING") + ); + return n == null ? 0L : n; + } catch (Exception e) { + log.warn("[ApprovalWorkflow] countPendingFromDb failed: {}", e.getMessage()); + return 0L; + } + } + + private Map toPendingPayload(ToolApprovalEntity entity) { + java.util.LinkedHashMap entry = new java.util.LinkedHashMap<>(); + entry.put("pendingId", entity.getPendingId()); + entry.put("conversationId", entity.getConversationId()); + entry.put("agentId", entity.getAgentId()); + entry.put("toolName", entity.getToolName()); + entry.put("toolArguments", entity.getToolArguments() != null ? entity.getToolArguments() : ""); + entry.put("status", "pending"); + entry.put("createdAt", entity.getCreatedAt() != null ? entity.getCreatedAt().toString() : null); + if (entity.getFindingsJson() != null) { + entry.put("findingsJson", entity.getFindingsJson()); + } + if (entity.getMaxSeverity() != null) { + entry.put("maxSeverity", entity.getMaxSeverity()); + } + if (entity.getSummary() != null) { + entry.put("summary", entity.getSummary()); + } + if (entity.getChannelType() != null) { + entry.put("channelType", entity.getChannelType()); + } + if (entity.getRequesterName() != null) { + entry.put("requesterName", entity.getRequesterName()); + } + return entry; + } + // ---------- shared two-phase machinery ---------- private ResolveOutcome performResolve(String pendingId, String userId, diff --git a/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java new file mode 100644 index 00000000..1f1396ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/notification/NotificationController.java @@ -0,0 +1,72 @@ +package vip.mate.notification; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.agent.runtime.AgentRuntimeAggregator; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Aggregate counts that drive global UI attention signals (sidebar badges, + * future notification center). + * + *

Designed so the frontend can poll a single endpoint instead of fan-out + * to every domain service. Fields with no settled "is it actually a problem" + * semantics (failed crons / down channels / down MCP servers) are returned + * as zero placeholders so the wire shape is stable and later phases can + * populate them without bumping the contract. + */ +@Slf4j +@Tag(name = "Notifications") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final ApprovalWorkflowService approvalWorkflowService; + private final AgentRuntimeAggregator agentRuntimeAggregator; + + @Operation(summary = "Aggregated counts for the sidebar attention badges") + @GetMapping("/summary") + public R> summary(Authentication auth) { + boolean admin = isAdmin(auth); + + // Cast to int — counts won't exceed Integer.MAX_VALUE in practice + // and the project's global Jackson config serializes Long as a string + // (for ID precision), which would break the numeric UI badge. + int pendingApprovals = (int) Math.min(Integer.MAX_VALUE, approvalWorkflowService.countPendingFromDb()); + int stuckAgents = admin + ? agentRuntimeAggregator.snapshot().summary().stuck() + : 0; + + Map payload = new LinkedHashMap<>(); + payload.put("pendingApprovals", pendingApprovals); + payload.put("stuckAgents", stuckAgents); + // Reserved fields — wire shape stays stable so the frontend doesn't + // need a fan-out when these get real semantics later. + payload.put("failedCrons", 0); + payload.put("downChannels", 0); + payload.put("downMcps", 0); + return R.ok(payload); + } + + private boolean isAdmin(Authentication auth) { + if (auth == null) { + throw new MateClawException(401, "authentication required"); + } + return auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java index 7e279fa9..26df385f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/controller/SecurityController.java @@ -178,11 +178,13 @@ public class SecurityController { @Operation(summary = "审批记录(管理视角)") @GetMapping("/approvals") public R listApprovals( - @RequestParam(required = false) String conversationId) { + @RequestParam(required = false) String conversationId, + @RequestParam(required = false, defaultValue = "0") int limit) { if (conversationId != null && !conversationId.isBlank()) { return R.ok(approvalWorkflowService.getPendingByConversation(conversationId)); } - // 返回空列表(后续可扩展为全量审批记录查询) - return R.ok(java.util.List.of()); + // Global view — reads from mate_tool_approval directly so the result + // survives in-memory map drift after a restart/recovery cycle. + return R.ok(approvalWorkflowService.listPendingFromDb(limit)); } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index a561e353..74469073 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -293,6 +293,19 @@ export const backstageApi = { sweep: () => http.post('/admin/agent-runtime/sweep'), } +// ==================== Notification summary (sidebar attention badges) ==================== +export interface NotificationSummary { + pendingApprovals: number + stuckAgents: number + failedCrons: number + downChannels: number + downMcps: number +} + +export const notificationApi = { + summary: () => http.get<{ data: NotificationSummary }>('/notifications/summary'), +} + // ==================== ACP Endpoints (RFC-090 Phase 7) ==================== export const acpApi = { list: () => http.get('/acp/endpoints'), diff --git a/mateclaw-ui/src/components/common/NavBadge.vue b/mateclaw-ui/src/components/common/NavBadge.vue new file mode 100644 index 00000000..34a7ff33 --- /dev/null +++ b/mateclaw-ui/src/components/common/NavBadge.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/mateclaw-ui/src/composables/useNotificationCenter.ts b/mateclaw-ui/src/composables/useNotificationCenter.ts new file mode 100644 index 00000000..2ce7e2e5 --- /dev/null +++ b/mateclaw-ui/src/composables/useNotificationCenter.ts @@ -0,0 +1,102 @@ +/** + * Shared notification-summary store + poller. + * + * Centralizes the counts that drive sidebar attention badges so every + * subscriber reads from the same cached snapshot — multiple mount/unmount + * cycles don't multiply HTTP traffic. + * + * Non-admin users never poll: the backend's per-user view today returns + * pendingApprovals only, and stuckAgents requires admin. The existing UI + * convention (MainLayout) already hides the global attention dot from + * non-admins, so we mirror it here. + */ +import { computed, onScopeDispose, ref } from 'vue' +import { notificationApi, type NotificationSummary } from '@/api' + +const POLL_INTERVAL_MS = 15_000 + +const summary = ref({ + pendingApprovals: 0, + stuckAgents: 0, + failedCrons: 0, + downChannels: 0, + downMcps: 0, +}) + +let refCount = 0 +let timer: ReturnType | null = null +let inFlight = false + +function isAdminRole(): boolean { + return (localStorage.getItem('role') || 'user') === 'admin' +} + +/** + * The server's global Jackson config serializes Long as a string for + * ID precision — counts can come through as either number or stringified + * number depending on whether the backend casts to int. Coerce here so + * the badge always sees a real number regardless of which side fixed it. + */ +function toCount(v: unknown): number { + if (typeof v === 'number') return Number.isFinite(v) ? v : 0 + if (typeof v === 'string') { + const n = Number(v) + return Number.isFinite(n) ? n : 0 + } + return 0 +} + +async function refresh(): Promise { + if (!isAdminRole()) return + if (inFlight) return + inFlight = true + try { + const res: any = await notificationApi.summary() + const raw = (res?.data ?? res) as Partial | undefined + if (raw) { + summary.value = { + pendingApprovals: toCount(raw.pendingApprovals), + stuckAgents: toCount(raw.stuckAgents), + failedCrons: toCount(raw.failedCrons), + downChannels: toCount(raw.downChannels), + downMcps: toCount(raw.downMcps), + } + } + } catch { + // Silent: stale counts beat a flapping badge. + } finally { + inFlight = false + } +} + +function ensurePolling(): void { + if (timer || !isAdminRole()) return + // Fire once immediately so the badge doesn't lag the interval. + void refresh() + timer = setInterval(refresh, POLL_INTERVAL_MS) +} + +function maybeStopPolling(): void { + if (refCount > 0) return + if (timer) { + clearInterval(timer) + timer = null + } +} + +export function useNotificationCenter() { + refCount++ + ensurePolling() + + onScopeDispose(() => { + refCount = Math.max(0, refCount - 1) + maybeStopPolling() + }) + + return { + summary: computed(() => summary.value), + pendingApprovals: computed(() => summary.value.pendingApprovals), + stuckAgents: computed(() => summary.value.stuckAgents), + refresh, + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 2bf8c53c..b20604e9 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -401,6 +401,9 @@ export default { roleUser: 'User', roleAdmin: 'Admin', }, + notifications: { + pendingApprovals: '{n} tool call(s) pending approval', + }, backstage: { kicker: 'Backstage', title: 'See what your employees are doing', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 578ae475..7a3fabad 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1714,6 +1714,9 @@ export default { testFailed: '连接失败,请检查配置', }, }, + notifications: { + pendingApprovals: '{n} 个工具调用等待审批', + }, backstage: { kicker: '后台', title: '看看你的数字员工在做什么', diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index 9f8c16d9..8a108f16 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -46,17 +46,26 @@ :key="item.path" :to="item.path" class="nav-item" - :class="{ active: isNavItemActive(item), 'has-attention': item.path === '/backstage' && backstageAlertActive }" + :class="{ active: isNavItemActive(item) }" :title="effectiveCollapsed ? (item.tooltip || item.label) : (item.tooltip || '')" @click="onNavClick" > {{ item.label }} - + /> + @@ -173,10 +182,12 @@ import { useI18n } from 'vue-i18n' import { useThemeStore } from '@/stores/useThemeStore' import { version as appVersion } from '../../../package.json' import type { ThemeMode } from '@/stores/useThemeStore' -import { http, settingsApi, setupApi, backstageApi } from '@/api/index' +import { http, settingsApi, setupApi } from '@/api/index' import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue' import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue' import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue' +import NavBadge from '@/components/common/NavBadge.vue' +import { useNotificationCenter } from '@/composables/useNotificationCenter' import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { applyLocale, currentLocale, type AppLocale } from '@/i18n' import { SwitchButton, Lock } from '@element-plus/icons-vue' @@ -210,24 +221,12 @@ async function fetchHealthStatus() { } } -// Live attention signal for the Backstage sidebar entry. Admins only — -// non-admin users never poll the runtime endpoint and never see the dot. -const backstageStuckCount = ref(0) -let backstagePollTimer: ReturnType | null = null - +// Sidebar attention signals — admin-only. Both `/backstage` (stuck agents) +// and `/security` (pending approvals) read from a shared 15s poller so +// multiple consumers don't multiply HTTP traffic. const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin') -const backstageAlertActive = computed(() => isAdminRole.value && backstageStuckCount.value > 0) - -async function refreshBackstageBadge() { - if (!isAdminRole.value) return - try { - const res: any = await backstageApi.snapshot() - const data = res?.data ?? res - backstageStuckCount.value = data?.summary?.stuck ?? 0 - } catch { - // Silent: stale value is preferable to a flapping indicator. - } -} +const { stuckAgents, pendingApprovals } = useNotificationCenter() +const backstageAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0) // 移动端状态 const isMobile = ref(false) @@ -274,18 +273,13 @@ onMounted(async () => { // Fetch initial health status for sidebar indicator fetchHealthStatus() - - // Backstage attention dot — poll every 15s for admins. - if (isAdminRole.value) { - refreshBackstageBadge() - backstagePollTimer = setInterval(refreshBackstageBadge, 15_000) - } + // Sidebar attention counts (backstage / security) are driven by + // useNotificationCenter — it polls when admins are mounted. }) onBeforeUnmount(() => { mobileQuery?.removeEventListener('change', handleMobileChange) mediumQuery?.removeEventListener('change', handleMediumChange) - if (backstagePollTimer) clearInterval(backstagePollTimer) }) function onNavClick() { @@ -673,44 +667,6 @@ watch(() => workspaceStore.currentWorkspaceId, () => { .nav-icon { display: flex; align-items: center; flex-shrink: 0; } .nav-label { overflow: hidden; text-overflow: ellipsis; } -/* Backstage attention dot — appears only when stuck > 0 for an admin. */ -.nav-attention-dot { - position: absolute; - right: 14px; - top: 50%; - width: 8px; - height: 8px; - border-radius: 50%; - background: hsl(20, 80%, 55%); - transform: translateY(-50%); - box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.6); - animation: nav-attention-pulse 2.4s ease-in-out infinite; -} - -.nav-item.has-attention { - color: hsl(20, 75%, 50%); -} - -@keyframes nav-attention-pulse { - 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: translateY(-50%) scale(1); } - 50% { box-shadow: 0 0 0 6px hsla(20, 80%, 55%, 0); transform: translateY(-50%) scale(1.15); } -} - -.sidebar.collapsed .nav-attention-dot { - right: 8px; - top: 8px; - transform: none; -} - -.sidebar.collapsed .nav-attention-dot { - animation-name: nav-attention-pulse-collapsed; -} - -@keyframes nav-attention-pulse-collapsed { - 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: scale(1); } - 50% { box-shadow: 0 0 0 5px hsla(20, 80%, 55%, 0); transform: scale(1.2); } -} - /* 底部 */ .sidebar-footer { border-top: 1px solid var(--mc-border-light);