feat: show log indices on OpenSearch admin page
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 54s
CI / docker (push) Successful in 47s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 35s

Add prefix query parameter to /admin/opensearch/indices endpoint so
the UI can fetch execution and log indices separately. OpenSearch admin
page now shows two card sections: Execution Indices and Log Indices,
each with doc count and size summary. Page restyled with CSS module
replacing inline styles. Delete endpoint also allows log index deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 16:47:44 +01:00
parent 0d94132c98
commit 25ca8d5132
4 changed files with 117 additions and 28 deletions

View File

@@ -49,12 +49,14 @@ public class OpenSearchAdminController {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String opensearchUrl; private final String opensearchUrl;
private final String indexPrefix; private final String indexPrefix;
private final String logIndexPrefix;
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient, public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
SearchIndexerStats indexerStats, AuditService auditService, SearchIndexerStats indexerStats, AuditService auditService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${opensearch.url:http://localhost:9200}") String opensearchUrl, @Value("${opensearch.url:http://localhost:9200}") String opensearchUrl,
@Value("${opensearch.index-prefix:executions-}") String indexPrefix) { @Value("${opensearch.index-prefix:executions-}") String indexPrefix,
@Value("${opensearch.log-index-prefix:logs-}") String logIndexPrefix) {
this.client = client; this.client = client;
this.restClient = restClient; this.restClient = restClient;
this.indexerStats = indexerStats; this.indexerStats = indexerStats;
@@ -62,6 +64,7 @@ public class OpenSearchAdminController {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.opensearchUrl = opensearchUrl; this.opensearchUrl = opensearchUrl;
this.indexPrefix = indexPrefix; this.indexPrefix = indexPrefix;
this.logIndexPrefix = logIndexPrefix;
} }
@GetMapping("/status") @GetMapping("/status")
@@ -100,7 +103,8 @@ public class OpenSearchAdminController {
public ResponseEntity<IndicesPageResponse> getIndices( public ResponseEntity<IndicesPageResponse> getIndices(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "") String search) { @RequestParam(defaultValue = "") String search,
@RequestParam(defaultValue = "executions") String prefix) {
try { try {
Response response = restClient.performRequest( Response response = restClient.performRequest(
new Request("GET", "/_cat/indices?format=json&h=index,health,docs.count,store.size,pri,rep&bytes=b")); new Request("GET", "/_cat/indices?format=json&h=index,health,docs.count,store.size,pri,rep&bytes=b"));
@@ -109,10 +113,12 @@ public class OpenSearchAdminController {
indices = objectMapper.readTree(is); indices = objectMapper.readTree(is);
} }
String filterPrefix = "logs".equals(prefix) ? logIndexPrefix : indexPrefix;
List<IndexInfoResponse> allIndices = new ArrayList<>(); List<IndexInfoResponse> allIndices = new ArrayList<>();
for (JsonNode idx : indices) { for (JsonNode idx : indices) {
String name = idx.path("index").asText(""); String name = idx.path("index").asText("");
if (!name.startsWith(indexPrefix)) { if (!name.startsWith(filterPrefix)) {
continue; continue;
} }
if (!search.isEmpty() && !name.contains(search)) { if (!search.isEmpty() && !name.contains(search)) {
@@ -152,7 +158,7 @@ public class OpenSearchAdminController {
@Operation(summary = "Delete an OpenSearch index") @Operation(summary = "Delete an OpenSearch index")
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) { public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
try { try {
if (!name.startsWith(indexPrefix)) { if (!name.startsWith(indexPrefix) && !name.startsWith(logIndexPrefix)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope");
} }
boolean exists = client.indices().exists(r -> r.index(name)).value(); boolean exists = client.indices().exists(r -> r.index(name)).value();

View File

@@ -71,13 +71,14 @@ export function usePipelineStats() {
}); });
} }
export function useOpenSearchIndices(page = 0, size = 20, search = '') { export function useOpenSearchIndices(page = 0, size = 20, search = '', prefix = 'executions') {
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'indices', page, size, search], queryKey: ['admin', 'opensearch', 'indices', prefix, page, size, search],
queryFn: () => { queryFn: () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('page', String(page)); params.set('page', String(page));
params.set('size', String(size)); params.set('size', String(size));
params.set('prefix', prefix);
if (search) params.set('search', search); if (search) params.set('search', search);
return adminFetch<IndicesPage>(`/opensearch/indices?${params}`); return adminFetch<IndicesPage>(`/opensearch/indices?${params}`);
}, },

View File

@@ -0,0 +1,63 @@
.statStrip {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.pipelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
margin-bottom: 16px;
}
.pipelineTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.pipelineMetrics {
display: flex;
gap: 24px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.pipelineMetrics span {
font-family: var(--font-mono);
}
.indexSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 16px;
overflow: hidden;
}
.indexHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.indexTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.indexMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}

View File

@@ -1,13 +1,14 @@
import { StatCard, Card, DataTable, Badge, ProgressBar, Spinner } from '@cameleer/design-system'; import { StatCard, DataTable, Badge, ProgressBar } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch'; import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch';
import { useState } from 'react'; import styles from './OpenSearchAdminPage.module.css';
export default function OpenSearchAdminPage() { export default function OpenSearchAdminPage() {
const { data: status } = useOpenSearchStatus(); const { data: status } = useOpenSearchStatus();
const { data: pipeline } = usePipelineStats(); const { data: pipeline } = usePipelineStats();
const { data: perf } = useOpenSearchPerformance(); const { data: perf } = useOpenSearchPerformance();
const { data: indicesData } = useOpenSearchIndices(); const { data: execIndices } = useOpenSearchIndices(0, 50, '', 'executions');
const { data: logIndices } = useOpenSearchIndices(0, 50, '', 'logs');
const deleteIndex = useDeleteIndex(); const deleteIndex = useDeleteIndex();
const indexColumns: Column<any>[] = [ const indexColumns: Column<any>[] = [
@@ -20,37 +21,55 @@ export default function OpenSearchAdminPage() {
return ( return (
<div> <div>
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2> <div className={styles.statStrip}>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Status" value={status?.reachable ? 'Connected' : 'Disconnected'} accent={status?.reachable ? 'success' : 'error'} /> <StatCard label="Status" value={status?.reachable ? 'Connected' : 'Disconnected'} accent={status?.reachable ? 'success' : 'error'} />
<StatCard label="Health" value={status?.clusterHealth ?? ''} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} /> <StatCard label="Health" value={status?.clusterHealth ?? '\u2014'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
<StatCard label="Version" value={status?.version ?? ''} /> <StatCard label="Version" value={status?.version ?? '\u2014'} />
<StatCard label="Nodes" value={status?.nodeCount ?? 0} /> <StatCard label="Nodes" value={status?.nodeCount ?? 0} />
</div> </div>
{pipeline && ( {pipeline && (
<Card> <div className={styles.pipelineCard}>
<div style={{ padding: '1rem' }}> <div className={styles.pipelineTitle}>Indexing Pipeline</div>
<h3 style={{ marginBottom: '0.5rem' }}>Indexing Pipeline</h3>
<ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} /> <ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} />
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}> <div className={styles.pipelineMetrics}>
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span> <span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
<span>Indexed: {pipeline.indexedCount}</span> <span>Indexed: {pipeline.indexedCount.toLocaleString()}</span>
<span>Failed: {pipeline.failedCount}</span> <span>Failed: {pipeline.failedCount}</span>
<span>Rate: {pipeline.indexingRate}/s</span> <span>Rate: {pipeline.indexingRate}/s</span>
</div> </div>
</div> </div>
</Card>
)} )}
<div style={{ marginTop: '1.5rem' }}> <div className={styles.indexSection}>
<h3 style={{ marginBottom: '0.75rem' }}>Indices</h3> <div className={styles.indexHeader}>
<span className={styles.indexTitle}>Execution Indices ({execIndices?.totalIndices ?? 0})</span>
<span className={styles.indexMeta}>
{execIndices ? `${execIndices.totalDocs.toLocaleString()} docs \u00b7 ${execIndices.totalSize}` : ''}
</span>
</div>
<DataTable <DataTable
columns={indexColumns} columns={indexColumns}
data={(indicesData?.indices || []).map((i: any) => ({ ...i, id: i.name }))} data={(execIndices?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
sortable sortable
pageSize={20} pageSize={20}
flush
/>
</div>
<div className={styles.indexSection}>
<div className={styles.indexHeader}>
<span className={styles.indexTitle}>Log Indices ({logIndices?.totalIndices ?? 0})</span>
<span className={styles.indexMeta}>
{logIndices ? `${logIndices.totalDocs.toLocaleString()} docs \u00b7 ${logIndices.totalSize}` : ''}
</span>
</div>
<DataTable
columns={indexColumns}
data={(logIndices?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
sortable
pageSize={20}
flush
/> />
</div> </div>
</div> </div>