feat: add search index rebuild workflow

This commit is contained in:
vsxd 2026-03-19 15:11:34 +08:00 committed by Xudong Sun
parent 049f5acb64
commit 024e66d747
14 changed files with 477 additions and 19 deletions

View file

@ -359,7 +359,7 @@
| title | varchar(256) | |
| summary | varchar(512) | |
| keywords | varchar(512) | |
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
| search_text | text | `displayName``slug``summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
| visibility | enum | 冗余,避免搜索时 join |
| status | enum | |
| updated_at | datetime | |

View file

@ -68,7 +68,7 @@ WHERE (visibility = 'PUBLIC')
| title | varchar(256) | |
| summary | varchar(512) | |
| keywords | varchar(512) | |
| search_text | text | SKILL.md 正文 + frontmatter 拼接 |
| search_text | text | `displayName``slug``summary`,以及 frontmatter 中除 `name` / `description` / `version` 外的字段展开结果 |
| visibility | enum | 冗余,避免搜索时 join |
| status | enum | |
| updated_at | datetime | |

View file

@ -11,8 +11,9 @@ description: 搜索和筛选技能
在搜索框输入关键词SkillHub 会在以下字段中搜索:
- 技能名称
- 技能描述
- SKILL.md 正文内容
- 关键词
- 技能 slug
- frontmatter 中除 `name``description``version` 外的其他字段
- `keywords` / `tags` 等关键词字段
## 筛选条件

View file

@ -11,8 +11,9 @@ description: Search and filter skills
Enter keywords in the search box, SkillHub searches in the following fields:
- Skill name
- Skill description
- SKILL.md body content
- Keywords
- Skill slug
- Frontmatter fields other than `name`, `description`, and `version`
- Keyword-style fields such as `keywords` and `tags`
## Filter Conditions

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import com.iflytek.skillhub.search.SearchRebuildService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Administrative maintenance endpoints for search-index operations reserved for super administrators.
*/
@RestController
@RequestMapping("/api/v1/admin/search")
public class AdminSearchController extends BaseApiController {
private final SearchRebuildService searchRebuildService;
private final AuditLogService auditLogService;
public AdminSearchController(ApiResponseFactory responseFactory,
SearchRebuildService searchRebuildService,
AuditLogService auditLogService) {
super(responseFactory);
this.searchRebuildService = searchRebuildService;
this.auditLogService = auditLogService;
}
@PostMapping("/rebuild")
@PreAuthorize("hasRole('SUPER_ADMIN')")
public ApiResponse<Void> rebuildAll(@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
searchRebuildService.rebuildAll();
auditLogService.record(
principal.userId(),
"REBUILD_SEARCH_INDEX",
"SEARCH_INDEX",
null,
MDC.get("requestId"),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"scope\":\"ALL\"}"
);
return ok("response.success.updated", null);
}
}

View file

@ -0,0 +1,82 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.search.SearchRebuildService;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.verify;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AdminSearchControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SearchRebuildService searchRebuildService;
@MockBean
private AuditLogService auditLogService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void rebuildAll_returnsOkForSuperAdmin() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN"));
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")));
mockMvc.perform(post("/api/v1/admin/search/rebuild")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(searchRebuildService).rebuildAll();
verify(auditLogService).record(
org.mockito.ArgumentMatchers.eq("admin"),
org.mockito.ArgumentMatchers.eq("REBUILD_SEARCH_INDEX"),
org.mockito.ArgumentMatchers.eq("SEARCH_INDEX"),
org.mockito.ArgumentMatchers.isNull(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq("{\"scope\":\"ALL\"}")
);
}
@Test
void rebuildAll_returnsForbiddenForSkillAdmin() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SKILL_ADMIN"));
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")));
mockMvc.perform(post("/api/v1/admin/search/rebuild")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}
}

View file

@ -1,35 +1,54 @@
package com.iflytek.skillhub.search.postgres;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SearchRebuildService;
import com.iflytek.skillhub.search.SkillSearchDocument;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
/**
* Reconstructs PostgreSQL search documents from canonical skill and namespace records.
*/
@Service
public class PostgresSearchRebuildService implements SearchRebuildService {
private static final Set<String> RESERVED_FRONTMATTER_FIELDS = Set.of("name", "description", "version");
private static final Set<String> KEYWORD_FIELD_NAMES = Set.of("keywords", "keyword", "tags", "tag");
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private final SkillRepository skillRepository;
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final SearchIndexService searchIndexService;
private final ObjectMapper objectMapper;
public PostgresSearchRebuildService(
SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SearchIndexService searchIndexService) {
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.searchIndexService = searchIndexService;
this.objectMapper = new ObjectMapper();
}
@Override
@ -61,16 +80,112 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
toDocument(skillOpt.get()).ifPresent(searchIndexService::index);
}
private String buildSearchText(Skill skill) {
StringBuilder sb = new StringBuilder();
if (skill.getDisplayName() != null) {
sb.append(skill.getDisplayName()).append(" ");
private SearchIndexPayload buildSearchPayload(Skill skill) {
List<String> searchParts = new ArrayList<>();
addPart(searchParts, skill.getDisplayName());
addPart(searchParts, skill.getSlug());
addPart(searchParts, skill.getSummary());
Set<String> keywords = new TreeSet<>();
resolveLatestVersion(skill)
.map(this::extractParsedMetadata)
.map(metadata -> metadata.get("frontmatter"))
.map(this::asMap)
.ifPresent(frontmatter -> appendFrontmatter(frontmatter, keywords, searchParts));
return new SearchIndexPayload(
String.join(", ", keywords),
String.join(" ", searchParts).trim()
);
}
private Optional<SkillVersion> resolveLatestVersion(Skill skill) {
if (skill.getLatestVersionId() == null) {
return Optional.empty();
}
sb.append(skill.getSlug()).append(" ");
if (skill.getSummary() != null) {
sb.append(skill.getSummary()).append(" ");
return skillVersionRepository.findById(skill.getLatestVersionId());
}
private Map<String, Object> extractParsedMetadata(SkillVersion version) {
String metadataJson = version.getParsedMetadataJson();
if (metadataJson == null || metadataJson.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(metadataJson, MAP_TYPE);
} catch (Exception e) {
return Map.of();
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> asMap(Object value) {
if (value instanceof Map<?, ?> map) {
return map.entrySet().stream()
.filter(entry -> entry.getKey() != null)
.collect(Collectors.toMap(entry -> String.valueOf(entry.getKey()), Map.Entry::getValue));
}
return Map.of();
}
private void appendFrontmatter(Map<String, Object> frontmatter, Set<String> keywords, List<String> searchParts) {
for (Map.Entry<String, Object> entry : frontmatter.entrySet()) {
String fieldName = entry.getKey();
Object value = entry.getValue();
if (value == null) {
continue;
}
if (KEYWORD_FIELD_NAMES.contains(fieldName.toLowerCase())) {
flattenToStrings(value).forEach(keyword -> {
String normalized = keyword.trim();
if (!normalized.isBlank()) {
keywords.add(normalized);
}
});
}
if (!RESERVED_FRONTMATTER_FIELDS.contains(fieldName.toLowerCase())) {
addPart(searchParts, fieldName);
flattenToStrings(value).forEach(text -> addPart(searchParts, text));
}
}
}
private List<String> flattenToStrings(Object value) {
if (value instanceof String text) {
return List.of(text);
}
if (value instanceof Number || value instanceof Boolean) {
return List.of(String.valueOf(value));
}
if (value instanceof Map<?, ?> map) {
List<String> values = new ArrayList<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() != null) {
values.add(String.valueOf(entry.getKey()));
}
values.addAll(flattenToStrings(entry.getValue()));
}
return values;
}
if (value instanceof Collection<?> collection) {
return collection.stream()
.filter(Objects::nonNull)
.flatMap(item -> flattenToStrings(item).stream())
.toList();
}
return List.of(String.valueOf(value));
}
private void addPart(List<String> parts, String value) {
if (value == null) {
return;
}
String normalized = value.trim();
if (!normalized.isBlank()) {
parts.add(normalized);
}
return sb.toString().trim();
}
private Optional<SkillSearchDocument> toDocument(Skill skill) {
@ -80,7 +195,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
}
Namespace namespace = namespaceOpt.get();
String searchText = buildSearchText(skill);
SearchIndexPayload payload = buildSearchPayload(skill);
return Optional.of(new SkillSearchDocument(
skill.getId(),
@ -89,11 +204,14 @@ public class PostgresSearchRebuildService implements SearchRebuildService {
skill.getOwnerId(),
skill.getDisplayName() != null ? skill.getDisplayName() : skill.getSlug(),
skill.getSummary(),
"",
searchText,
payload.keywords(),
payload.searchText(),
null,
skill.getVisibility().name(),
skill.getStatus().name()
));
}
private record SearchIndexPayload(String keywords, String searchText) {
}
}

View file

@ -0,0 +1,87 @@
package com.iflytek.skillhub.search.postgres;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.search.SearchIndexService;
import com.iflytek.skillhub.search.SkillSearchDocument;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PostgresSearchRebuildServiceTest {
@Test
void rebuildBySkill_shouldIndexFrontmatterFieldsAndKeywordsWithoutBody() {
SkillRepository skillRepository = mock(SkillRepository.class);
NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
SearchIndexService searchIndexService = mock(SearchIndexService.class);
Skill skill = new Skill(7L, "smart-agent", "owner-1", SkillVisibility.PUBLIC);
skill.setDisplayName("Smart Agent");
skill.setSummary("Builds workflows");
skill.setLatestVersionId(99L);
Namespace namespace = new Namespace("team-ai", "Team AI", "owner-1");
SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1");
version.setParsedMetadataJson("""
{
"name": "Smart Agent",
"description": "Builds workflows",
"version": "1.2.0",
"body": "# ignored",
"frontmatter": {
"name": "Smart Agent",
"description": "Builds workflows",
"version": "1.2.0",
"author": "Jane Doe",
"tags": ["automation", "agentic"],
"keywords": ["workflow", "assistant"],
"config": {
"provider": "openai"
}
}
}
""");
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(7L)).thenReturn(Optional.of(namespace));
when(skillVersionRepository.findById(99L)).thenReturn(Optional.of(version));
PostgresSearchRebuildService service = new PostgresSearchRebuildService(
skillRepository,
namespaceRepository,
skillVersionRepository,
searchIndexService
);
service.rebuildBySkill(1L);
ArgumentCaptor<SkillSearchDocument> captor = ArgumentCaptor.forClass(SkillSearchDocument.class);
verify(searchIndexService).index(captor.capture());
SkillSearchDocument document = captor.getValue();
assertThat(document.keywords()).isEqualTo("agentic, assistant, automation, workflow");
assertThat(document.searchText()).contains("Smart Agent");
assertThat(document.searchText()).contains("smart-agent");
assertThat(document.searchText()).contains("Builds workflows");
assertThat(document.searchText()).contains("author");
assertThat(document.searchText()).contains("Jane Doe");
assertThat(document.searchText()).contains("config");
assertThat(document.searchText()).contains("provider");
assertThat(document.searchText()).contains("openai");
assertThat(document.searchText()).doesNotContain("# ignored");
}
}

View file

@ -831,6 +831,13 @@ export const governanceApi = {
headers: getCsrfHeaders(),
})
},
async rebuildSearchIndex(): Promise<void> {
await fetchJson<void>('/api/v1/admin/search/rebuild', {
method: 'POST',
headers: getCsrfHeaders(),
})
},
}
export const meApi = {

View file

@ -49,3 +49,9 @@ export function useMarkGovernanceNotificationRead() {
},
})
}
export function useRebuildSearchIndex() {
return useMutation({
mutationFn: () => governanceApi.rebuildSearchIndex(),
})
}

View file

@ -493,6 +493,9 @@
"filterUnhideSkill": "Skill Unhidden",
"filterUnarchiveSkill": "Skill Restored",
"filterYankVersion": "Version Yanked",
"filterRebuildSearchIndex": "Search Index Rebuilt",
"quickFilterSearchRebuild": "Search index rebuilds only",
"clearFilters": "Clear filters",
"userIdPlaceholder": "User ID...",
"requestIdPlaceholder": "Request ID...",
"ipPlaceholder": "IP address...",
@ -762,7 +765,17 @@
"activityTitle": "Governance activity",
"activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.",
"emptyActivity": "No recent governance activity.",
"unknownActor": "Unknown actor"
"unknownActor": "Unknown actor",
"searchMaintenanceTitle": "Search Index Maintenance",
"searchMaintenanceDescription": "Rebuild the full skill search index. This action is available only to super administrators and is intended for full backfills after search rule changes.",
"searchMaintenanceHint": "This may take a while. Avoid triggering it repeatedly.",
"searchRebuildAction": "Rebuild full search index",
"searchRebuildRunning": "Rebuilding...",
"searchRebuildConfirmTitle": "Rebuild the full search index?",
"searchRebuildConfirmDescription": "The system will rebuild search documents for all skills using the current indexing rules. This operation may take some time.",
"searchRebuildSuccessTitle": "Search index rebuild started",
"searchRebuildSuccessDescription": "The system is rebuilding the full search index using the current rules.",
"searchRebuildErrorTitle": "Search index rebuild failed"
},
"members": {
"title": "Member Management",

View file

@ -493,6 +493,9 @@
"filterUnhideSkill": "恢复隐藏",
"filterUnarchiveSkill": "恢复归档",
"filterYankVersion": "版本撤回",
"filterRebuildSearchIndex": "重建搜索索引",
"quickFilterSearchRebuild": "仅看搜索索引重建",
"clearFilters": "清空筛选",
"userIdPlaceholder": "用户 ID...",
"requestIdPlaceholder": "请求 ID...",
"ipPlaceholder": "IP 地址...",
@ -762,7 +765,17 @@
"activityTitle": "治理活动",
"activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。",
"emptyActivity": "暂无治理活动。",
"unknownActor": "未知操作者"
"unknownActor": "未知操作者",
"searchMaintenanceTitle": "搜索索引维护",
"searchMaintenanceDescription": "重新构建全部技能搜索索引。该操作仅超级管理员可执行,适用于搜索规则变更后的全量回填。",
"searchMaintenanceHint": "执行期间可能耗时较长,请避免频繁触发。",
"searchRebuildAction": "重建全部搜索索引",
"searchRebuildRunning": "重建中...",
"searchRebuildConfirmTitle": "确认重建全部搜索索引?",
"searchRebuildConfirmDescription": "系统会按当前搜索规则重建所有技能的搜索文档。该操作可能持续一段时间。",
"searchRebuildSuccessTitle": "已触发搜索索引重建",
"searchRebuildSuccessDescription": "系统正在按当前规则重建全部搜索索引。",
"searchRebuildErrorTitle": "搜索索引重建失败"
},
"members": {
"title": "成员管理",

View file

@ -33,6 +33,7 @@ const ACTION_OPTIONS = [
{ value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' },
{ value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' },
{ value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' },
{ value: 'REBUILD_SEARCH_INDEX', labelKey: 'auditLog.filterRebuildSearchIndex' },
] as const
/**
@ -68,6 +69,24 @@ export function AuditLogPage() {
return formatLocalDateTime(dateString, i18n.language)
}
const applySearchIndexRebuildFilter = () => {
setActionFilter('REBUILD_SEARCH_INDEX')
setResourceTypeFilter('SEARCH_INDEX')
setPage(0)
}
const clearFilters = () => {
setActionFilter('')
setUserIdFilter('')
setRequestIdFilter('')
setIpFilter('')
setResourceTypeFilter('')
setResourceIdFilter('')
setStartTimeFilter('')
setEndTimeFilter('')
setPage(0)
}
return (
<div className="space-y-8 animate-fade-up">
<div>
@ -76,6 +95,15 @@ export function AuditLogPage() {
</div>
<Card className="p-5">
<div className="mb-4 flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={applySearchIndexRebuildFilter}>
{t('auditLog.quickFilterSearchRebuild')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={clearFilters}>
{t('auditLog.clearFilters')}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Select value={actionFilter} onChange={(e) => {
setActionFilter(e.target.value)

View file

@ -1,6 +1,10 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { toast } from '@/shared/lib/toast'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { GovernanceInbox } from '@/features/governance/governance-inbox'
@ -10,6 +14,7 @@ import {
useGovernanceActivity,
useGovernanceInbox,
useGovernanceNotifications,
useRebuildSearchIndex,
useGovernanceSummary,
useMarkGovernanceNotificationRead,
} from '@/features/governance/use-governance'
@ -31,14 +36,28 @@ function SummaryCard({ label, value }: { label: string; value?: number }) {
export function GovernancePage() {
const { t } = useTranslation()
const { hasRole } = useAuth()
const [inboxType, setInboxType] = useState<GovernanceInboxTab>('ALL')
const [rebuildDialogOpen, setRebuildDialogOpen] = useState(false)
const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary()
const { data: inboxItems, isLoading: isInboxLoading } = useGovernanceInbox(inboxType === 'ALL' ? undefined : inboxType)
const { data: activityItems, isLoading: isActivityLoading } = useGovernanceActivity()
const { data: notifications, isLoading: isNotificationsLoading } = useGovernanceNotifications()
const markReadMutation = useMarkGovernanceNotificationRead()
const rebuildSearchIndexMutation = useRebuildSearchIndex()
const unreadCount = notifications?.filter((item) => item.status === 'UNREAD').length ?? 0
const canRebuildSearchIndex = hasRole('SUPER_ADMIN')
const handleRebuildSearchIndex = async () => {
try {
await rebuildSearchIndexMutation.mutateAsync()
toast.success(t('governance.searchRebuildSuccessTitle'), t('governance.searchRebuildSuccessDescription'))
} catch (error) {
toast.error(t('governance.searchRebuildErrorTitle'), error instanceof Error ? error.message : '')
throw error
}
}
return (
<div className="space-y-8 animate-fade-up">
@ -101,6 +120,37 @@ export function GovernancePage() {
<GovernanceActivity items={activityItems} isLoading={isActivityLoading} />
</Card>
</div>
{canRebuildSearchIndex ? (
<>
<Card className="p-5 space-y-4 border border-amber-500/20 bg-amber-500/5">
<div className="space-y-1">
<h2 className="text-xl font-semibold font-heading">{t('governance.searchMaintenanceTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('governance.searchMaintenanceDescription')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setRebuildDialogOpen(true)}
disabled={rebuildSearchIndexMutation.isPending}
>
{rebuildSearchIndexMutation.isPending ? t('governance.searchRebuildRunning') : t('governance.searchRebuildAction')}
</Button>
<span className="text-xs text-muted-foreground">{t('governance.searchMaintenanceHint')}</span>
</div>
</Card>
<ConfirmDialog
open={rebuildDialogOpen}
onOpenChange={setRebuildDialogOpen}
title={t('governance.searchRebuildConfirmTitle')}
description={t('governance.searchRebuildConfirmDescription')}
confirmText={t('governance.searchRebuildAction')}
onConfirm={handleRebuildSearchIndex}
/>
</>
) : null}
</div>
)
}