Skip to content

Commit 22c3626

Browse files
committed
Add core info and core management features
Backend: introduce TurSECoreInfo (name, numDocs) and update TurSolrUtils.listCores to return core info (reads index.numDocs). Add TurSolrUtils.clearCore and API endpoints to delete a core and to clear all documents from a core (/se/{id}/cores/{core} and /se/{id}/cores/{core}/documents). Frontend: add TurSECoreInfo model and service methods (getCores, deleteCore, clearCore). Update cores page to display grouped cores/locales, show document counts, and provide UI actions to delete cores or remove all documents. Also enhance DialogDelete to accept custom trigger, title, description and confirmLabel for reuse.
1 parent 787bab9 commit 22c3626

File tree

7 files changed

+277
-36
lines changed

7 files changed

+277
-36
lines changed

turing-app/src/main/java/com/viglet/turing/api/se/TurSEInstanceAPI.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import com.viglet.turing.solr.TurSolr;
4343
import com.viglet.turing.solr.TurSolrInstanceProcess;
4444
import com.viglet.turing.solr.TurSolrUtils;
45+
import com.viglet.turing.solr.bean.TurSECoreInfo;
4546
import com.viglet.turing.spring.utils.TurPersistenceUtils;
4647

4748
import io.swagger.v3.oas.annotations.Operation;
@@ -125,12 +126,30 @@ public TurSEInstanceDto turSEInstanceAdd(@RequestBody TurSEInstanceDto turSEInst
125126

126127
@Operation(summary = "List Solr cores/collections for a Search Engine")
127128
@GetMapping("/{id}/cores")
128-
public ResponseEntity<List<String>> turSEInstanceCores(@PathVariable String id) {
129+
public ResponseEntity<List<TurSECoreInfo>> turSEInstanceCores(@PathVariable String id) {
129130
return turSEInstanceRepository.findById(id)
130131
.map(turSEInstance -> ResponseEntity.ok(TurSolrUtils.listCores(turSEInstance)))
131132
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build());
132133
}
133134

135+
@Operation(summary = "Delete a Solr core/collection from a Search Engine")
136+
@DeleteMapping("/{id}/cores/{core}")
137+
public ResponseEntity<Void> turSEInstanceDeleteCore(@PathVariable String id, @PathVariable String core) {
138+
return turSEInstanceRepository.findById(id).map(turSEInstance -> {
139+
TurSolrUtils.deleteCore(turSEInstance, core);
140+
return ResponseEntity.noContent().<Void>build();
141+
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).<Void>build());
142+
}
143+
144+
@Operation(summary = "Remove all documents from a Solr core/collection")
145+
@DeleteMapping("/{id}/cores/{core}/documents")
146+
public ResponseEntity<Void> turSEInstanceClearCore(@PathVariable String id, @PathVariable String core) {
147+
return turSEInstanceRepository.findById(id).map(turSEInstance -> {
148+
TurSolrUtils.clearCore(turSEInstance, core);
149+
return ResponseEntity.noContent().<Void>build();
150+
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).<Void>build());
151+
}
152+
134153
@GetMapping("/{id}/{core}/select")
135154
public ResponseEntity<TurSEResults> turSEInstanceSelect(@PathVariable String id, @PathVariable String core,
136155
@ModelAttribute TurSNSearchParams turSNSearchParams) {

turing-app/src/main/java/com/viglet/turing/solr/TurSolrUtils.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import java.net.http.HttpRequest;
2828
import java.net.http.HttpRequest.BodyPublishers;
2929
import java.net.http.HttpResponse;
30-
import java.util.ArrayList;
3130
import java.util.Collections;
3231
import java.util.LinkedHashMap;
3332
import java.util.List;
@@ -51,6 +50,7 @@
5150
import com.viglet.turing.commons.utils.TurCommonsUtils;
5251
import com.viglet.turing.persistence.model.se.TurSEInstance;
5352
import com.viglet.turing.se.result.TurSEResult;
53+
import com.viglet.turing.solr.bean.TurSECoreInfo;
5454
import com.viglet.turing.solr.bean.TurSolrFieldBean;
5555

5656
import lombok.extern.slf4j.Slf4j;
@@ -71,6 +71,16 @@ public static void deleteCore(TurSEInstance turSEInstance, String coreName) {
7171
TurSolrUtils.deleteCore(getSolrUrl(turSEInstance), coreName);
7272
}
7373

74+
public static void clearCore(TurSEInstance turSEInstance, String coreName) {
75+
String json = "{\"delete\":{\"query\":\"*:*\"}}";
76+
HttpRequest request = getHttpRequestBuilderJson()
77+
.uri(URI.create(String.format("%s/solr/%s/update?commit=true",
78+
getSolrUrl(turSEInstance), coreName)))
79+
.POST(BodyPublishers.ofString(json))
80+
.build();
81+
executeRequest(request, "Failed to clear core: " + coreName);
82+
}
83+
7484
private static String getSolrUrl(TurSEInstance turSEInstance) {
7585
try {
7686
URI uri = URI.create(turSEInstance.getEndpointUrl());
@@ -355,7 +365,7 @@ public static int lastRowPositionFromCurrentPage(TurSEParameters turSEParameters
355365
return (turSEParameters.getCurrentPage() * turSEParameters.getRows());
356366
}
357367

358-
public static List<String> listCores(TurSEInstance turSEInstance) {
368+
public static List<TurSECoreInfo> listCores(TurSEInstance turSEInstance) {
359369
HttpRequest request = getHttpRequestBuilderJson()
360370
.uri(URI.create(String.format("%s/api/cores", getSolrUrl(turSEInstance))))
361371
.GET()
@@ -369,12 +379,17 @@ public static List<String> listCores(TurSEInstance turSEInstance) {
369379
DocumentContext jsonContext = JsonPath.parse(body, configuration);
370380
Object statusObj = jsonContext.read("$.status");
371381
if (statusObj instanceof Map<?, ?> statusMap) {
372-
return new ArrayList<>(statusMap.keySet().stream()
382+
return statusMap.keySet().stream()
373383
.map(Object::toString)
374384
.sorted()
375-
.toList());
385+
.map(name -> {
386+
Number numDocsRaw = jsonContext.read("$.status." + name + ".index.numDocs");
387+
long numDocs = numDocsRaw != null ? numDocsRaw.longValue() : 0L;
388+
return new TurSECoreInfo(name, numDocs);
389+
})
390+
.toList();
376391
}
377-
return Collections.<String>emptyList();
392+
return Collections.<TurSECoreInfo>emptyList();
378393
})
379394
.orElse(Collections.emptyList());
380395
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.viglet.turing.solr.bean;
2+
3+
import java.util.List;
4+
5+
public record TurSECoreInfo(String name, long numDocs, List<String> usedBySites) {
6+
}

turing-react/src/app/console/se/cores/se.instance.cores.page.tsx

Lines changed: 194 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,220 @@
11
import { ROUTES } from "@/app/routes.const";
22
import { BlankSlate } from "@/components/blank-slate";
3+
import { BadgeLocale } from "@/components/badge-locale";
4+
import { DialogDelete } from "@/components/dialog.delete";
35
import { LoadProvider } from "@/components/loading-provider";
46
import { SubPageHeader } from "@/components/sub.page.header";
5-
import { Badge } from "@/components/ui/badge";
7+
import { Button } from "@/components/ui/button";
8+
import type { TurSECoreInfo } from "@/models/se/se-core-info.model.ts";
69
import { TurSEInstanceService } from "@/services/se/se.service";
7-
import { IconDatabase } from "@tabler/icons-react";
10+
import {
11+
IconChevronDown,
12+
IconDatabase,
13+
IconEraser,
14+
IconServer,
15+
IconTrash,
16+
} from "@tabler/icons-react";
817
import { useEffect, useState } from "react";
918
import { useParams } from "react-router-dom";
1019

1120
const turSEInstanceService = new TurSEInstanceService();
1221

22+
// --- Core grouping ---
23+
24+
interface LocaleEntry {
25+
locale: string;
26+
coreInfo: TurSECoreInfo;
27+
}
28+
29+
interface CoreGroup {
30+
base: string;
31+
locales: LocaleEntry[];
32+
}
33+
34+
// Matches: {base}_{lang}_{COUNTRY} or {base}_{lang}
35+
const LOCALE_PATTERN = /^(.+)_([a-z]{2}_[A-Z]{2})$|^(.+)_([a-z]{2})$/;
36+
37+
function groupCores(cores: TurSECoreInfo[]): CoreGroup[] {
38+
const map = new Map<string, LocaleEntry[]>();
39+
40+
for (const coreInfo of cores) {
41+
const m = LOCALE_PATTERN.exec(coreInfo.name);
42+
if (m) {
43+
const base = m[1] ?? m[3];
44+
const locale = m[2] ?? m[4];
45+
if (!map.has(base)) map.set(base, []);
46+
map.get(base)?.push({ locale, coreInfo });
47+
} else if (!map.has(coreInfo.name)) {
48+
map.set(coreInfo.name, []);
49+
}
50+
}
51+
52+
return Array.from(map.entries())
53+
.sort(([a], [b]) => a.localeCompare(b))
54+
.map(([base, locales]) => ({ base, locales }));
55+
}
56+
57+
function formatNumDocs(n: number): string {
58+
return n.toLocaleString();
59+
}
60+
61+
// --- Locale row with actions ---
62+
63+
interface LocaleRowProps {
64+
locale: string;
65+
coreInfo: TurSECoreInfo;
66+
onDelete: (coreName: string) => void;
67+
onClear: (coreName: string) => void;
68+
}
69+
70+
function LocaleRow({ locale, coreInfo, onDelete, onClear }: Readonly<LocaleRowProps>) {
71+
const [deleteOpen, setDeleteOpen] = useState(false);
72+
const [clearOpen, setClearOpen] = useState(false);
73+
74+
return (
75+
<div className="flex items-center gap-3 px-4 py-2.5 pl-10 bg-muted/20">
76+
<BadgeLocale locale={locale} />
77+
<span className="text-sm text-muted-foreground flex-1 font-mono">{coreInfo.name}</span>
78+
<span className="text-xs text-muted-foreground tabular-nums">
79+
{formatNumDocs(coreInfo.numDocs)} docs
80+
</span>
81+
<div className="flex items-center gap-1">
82+
<DialogDelete
83+
feature="documents"
84+
name={coreInfo.name}
85+
open={clearOpen}
86+
setOpen={setClearOpen}
87+
onDelete={() => { setClearOpen(false); onClear(coreInfo.name); }}
88+
trigger={
89+
<Button variant="ghost" size="icon-sm" title="Remove all documents">
90+
<IconEraser className="size-4" />
91+
</Button>
92+
}
93+
title="Remove all documents?"
94+
description="This will delete all indexed documents from this core."
95+
confirmLabel="Yes, remove all documents"
96+
/>
97+
<DialogDelete
98+
feature="core"
99+
name={coreInfo.name}
100+
open={deleteOpen}
101+
setOpen={setDeleteOpen}
102+
onDelete={() => { setDeleteOpen(false); onDelete(coreInfo.name); }}
103+
trigger={
104+
<Button variant="ghost" size="icon-sm" title="Delete core">
105+
<IconTrash className="size-4" />
106+
</Button>
107+
}
108+
/>
109+
</div>
110+
</div>
111+
);
112+
}
113+
114+
// --- Group row ---
115+
116+
interface CoreGroupRowProps {
117+
group: CoreGroup;
118+
onDelete: (coreName: string) => void;
119+
onClear: (coreName: string) => void;
120+
}
121+
122+
function CoreGroupRow({ group, onDelete, onClear }: Readonly<CoreGroupRowProps>) {
123+
const [open, setOpen] = useState(true);
124+
const hasLocales = group.locales.length > 0;
125+
const localeCount = group.locales.length;
126+
const totalDocs = group.locales.reduce((sum, { coreInfo }) => sum + coreInfo.numDocs, 0);
127+
128+
return (
129+
<div className="rounded-lg border bg-background overflow-hidden">
130+
<button
131+
type="button"
132+
onClick={() => hasLocales && setOpen((v) => !v)}
133+
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/50 transition-colors"
134+
>
135+
<IconServer className="size-4 text-muted-foreground shrink-0" />
136+
<span className="text-sm font-semibold flex-1">{group.base}</span>
137+
{hasLocales && (
138+
<>
139+
<span className="text-xs text-muted-foreground">
140+
{formatNumDocs(totalDocs)} docs
141+
</span>
142+
<span className="text-xs text-muted-foreground">·</span>
143+
<span className="text-xs text-muted-foreground">
144+
{localeCount} locale{localeCount === 1 ? "" : "s"}
145+
</span>
146+
<IconChevronDown
147+
className={`size-4 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
148+
/>
149+
</>
150+
)}
151+
</button>
152+
153+
{hasLocales && open && (
154+
<div className="border-t divide-y">
155+
{group.locales.map(({ locale, coreInfo }) => (
156+
<LocaleRow
157+
key={coreInfo.name}
158+
locale={locale}
159+
coreInfo={coreInfo}
160+
onDelete={onDelete}
161+
onClear={onClear}
162+
/>
163+
))}
164+
</div>
165+
)}
166+
</div>
167+
);
168+
}
169+
13170
export default function SEInstanceCoresPage() {
14171
const { id } = useParams() as { id: string };
15-
const [cores, setCores] = useState<string[]>();
172+
const [cores, setCores] = useState<TurSECoreInfo[]>();
16173
const [error, setError] = useState<string | null>(null);
17174

18-
useEffect(() => {
175+
const loadCores = () => {
19176
turSEInstanceService.getCores(id)
20177
.then(setCores)
21178
.catch(() => setError("Connection error or timeout while fetching Solr cores."));
179+
};
180+
181+
useEffect(() => {
182+
loadCores();
22183
}, [id]);
23184

185+
const handleDelete = (coreName: string) => {
186+
turSEInstanceService.deleteCore(id, coreName)
187+
.then(loadCores)
188+
.catch(() => setError(`Failed to delete core: ${coreName}`));
189+
};
190+
191+
const handleClear = (coreName: string) => {
192+
turSEInstanceService.clearCore(id, coreName)
193+
.then(loadCores)
194+
.catch(() => setError(`Failed to remove documents from: ${coreName}`));
195+
};
196+
197+
const groups = cores ? groupCores(cores) : [];
198+
24199
return (
25200
<LoadProvider checkIsNotUndefined={cores} error={error} tryAgainUrl={`${ROUTES.SE_INSTANCE}/${id}/cores`}>
26201
{cores && cores.length > 0 ? (
27202
<>
28-
<SubPageHeader icon={IconDatabase} name="Cores" feature="Cores"
29-
description="Solr cores and collections available in this Search Engine." />
30-
<div className="px-6 pb-6">
31-
<div className="grid gap-2">
32-
{cores.map((core) => (
33-
<div key={core}
34-
className="flex items-center gap-3 rounded-lg border px-4 py-3 bg-background">
35-
<IconDatabase className="size-4 text-muted-foreground shrink-0" />
36-
<span className="text-sm font-medium flex-1">{core}</span>
37-
<Badge variant="secondary">core</Badge>
38-
</div>
39-
))}
40-
</div>
203+
<SubPageHeader
204+
icon={IconDatabase}
205+
name="Cores"
206+
feature="Cores"
207+
description="Solr cores and collections available in this Search Engine."
208+
/>
209+
<div className="px-6 pb-6 space-y-2">
210+
{groups.map((group) => (
211+
<CoreGroupRow
212+
key={group.base}
213+
group={group}
214+
onDelete={handleDelete}
215+
onClear={handleClear}
216+
/>
217+
))}
41218
</div>
42219
</>
43220
) : (

0 commit comments

Comments
 (0)