feat(ui): surface pending approvals + stuck agents as sidebar badges

This commit is contained in:
matevip 2026-05-15 10:17:30 +08:00
parent adf5d93975
commit eb6badeb61
9 changed files with 429 additions and 71 deletions

View File

@ -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.
*
* <p>Payload shape matches {@link ApprovalService#getPendingByConversation},
* so the same frontend renderer can consume both surfaces.
*/
public List<Map<String, Object>> listPendingFromDb(int limit) {
int effectiveLimit = limit <= 0 ? DEFAULT_PENDING_LIST_LIMIT
: Math.min(limit, MAX_PENDING_LIST_LIMIT);
List<ToolApprovalEntity> rows;
try {
rows = approvalMapper.selectList(
new LambdaQueryWrapper<ToolApprovalEntity>()
.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<ToolApprovalEntity>()
.eq(ToolApprovalEntity::getStatus, "PENDING")
);
return n == null ? 0L : n;
} catch (Exception e) {
log.warn("[ApprovalWorkflow] countPendingFromDb failed: {}", e.getMessage());
return 0L;
}
}
private Map<String, Object> toPendingPayload(ToolApprovalEntity entity) {
java.util.LinkedHashMap<String, Object> 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,

View File

@ -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).
*
* <p>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<Map<String, Object>> 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<String, Object> 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);
}
}

View File

@ -178,11 +178,13 @@ public class SecurityController {
@Operation(summary = "审批记录(管理视角)")
@GetMapping("/approvals")
public R<Object> 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));
}
}

View File

@ -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'),

View File

@ -0,0 +1,123 @@
<template>
<span
v-if="visible"
class="nav-badge"
:class="[`nav-badge--${tone}`, badgeMode, { 'is-collapsed': collapsed }]"
:title="title"
>
<template v-if="mode === 'count'">{{ display }}</template>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
/**
* Sidebar attention badge. Two modes:
* - dot: silent presence indicator (replaces the legacy backstage pulse).
* - count: small pill with an actionable number (e.g. pending approvals).
*
* When the host nav item collapses, the badge floats to the top-right corner
* so it stays visible without competing with the icon.
*/
const props = withDefaults(
defineProps<{
/** Count to render; <= 0 hides the badge in count mode. */
count?: number
/** Force dot mode regardless of count (used for "something needs attention" signals without a number). */
dot?: boolean
/** Visual severity. urgent = red, warning = orange (matches the legacy backstage tint). */
tone?: 'urgent' | 'warning'
/** Whether the surrounding sidebar is collapsed; controls corner placement. */
collapsed?: boolean
/** Optional tooltip on hover. */
title?: string
}>(),
{
count: 0,
dot: false,
tone: 'urgent',
collapsed: false,
title: '',
}
)
const mode = computed<'dot' | 'count'>(() => (props.dot ? 'dot' : 'count'))
const visible = computed(() => (mode.value === 'dot' ? true : props.count > 0))
const display = computed(() => (props.count > 99 ? '99+' : String(props.count)))
const badgeMode = computed(() => `nav-badge--${mode.value}`)
</script>
<style scoped>
.nav-badge {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
line-height: 1;
pointer-events: none;
}
/* Tone — urgent (red) */
.nav-badge--urgent {
--badge-bg: hsl(0, 75%, 55%);
--badge-pulse: hsla(0, 75%, 55%, 0.55);
}
/* Tone — warning (orange) — matches the legacy backstage attention color */
.nav-badge--warning {
--badge-bg: hsl(20, 80%, 55%);
--badge-pulse: hsla(20, 80%, 55%, 0.55);
}
/* Dot mode: pulsing 8px circle */
.nav-badge--dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--badge-bg);
animation: nav-badge-pulse 2.4s ease-in-out infinite;
}
/* Count mode: rounded pill, no pulse (the changing number is the signal) */
.nav-badge--count {
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
color: #fff;
background: var(--badge-bg);
}
@keyframes nav-badge-pulse {
0%, 100% { transform: translateY(-50%) scale(1); box-shadow: 0 0 0 0 var(--badge-pulse); }
50% { transform: translateY(-50%) scale(1.15); box-shadow: 0 0 0 6px transparent; }
}
/* Collapsed sidebar — float to the top-right corner. */
.nav-badge.is-collapsed {
right: 6px;
top: 6px;
transform: none;
}
.nav-badge--dot.is-collapsed {
animation-name: nav-badge-pulse-corner;
}
.nav-badge--count.is-collapsed {
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
border-radius: 8px;
}
@keyframes nav-badge-pulse-corner {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 var(--badge-pulse); }
50% { transform: scale(1.2); box-shadow: 0 0 0 5px transparent; }
}
</style>

View File

@ -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<NotificationSummary>({
pendingApprovals: 0,
stuckAgents: 0,
failedCrons: 0,
downChannels: 0,
downMcps: 0,
})
let refCount = 0
let timer: ReturnType<typeof setInterval> | 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<void> {
if (!isAdminRole()) return
if (inFlight) return
inFlight = true
try {
const res: any = await notificationApi.summary()
const raw = (res?.data ?? res) as Partial<NotificationSummary> | 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,
}
}

View File

@ -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',

View File

@ -1714,6 +1714,9 @@ export default {
testFailed: '连接失败,请检查配置',
},
},
notifications: {
pendingApprovals: '{n} 个工具调用等待审批',
},
backstage: {
kicker: '后台',
title: '看看你的数字员工在做什么',

View File

@ -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"
>
<span class="nav-icon" v-html="item.icon"></span>
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
<span
v-if="item.path === '/backstage' && backstageAlertActive"
class="nav-attention-dot"
<NavBadge
v-if="item.path === '/backstage'"
:dot="backstageAlertActive"
tone="warning"
:collapsed="effectiveCollapsed"
:title="t('backstage.attention')"
></span>
/>
<NavBadge
v-else-if="item.path === '/security' && isAdminRole"
:count="pendingApprovals"
tone="urgent"
:collapsed="effectiveCollapsed"
:title="t('notifications.pendingApprovals', { n: pendingApprovals })"
/>
</router-link>
</div>
</template>
@ -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<typeof setInterval> | 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);