feat: show log indices on OpenSearch admin page
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:
@@ -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();
|
||||||
|
|||||||
@@ -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}`);
|
||||||
},
|
},
|
||||||
|
|||||||
63
ui/src/pages/Admin/OpenSearchAdminPage.module.css
Normal file
63
ui/src/pages/Admin/OpenSearchAdminPage.module.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user