Merge pull request #248 from scub-france/feature/243-stores
feat(#243,#244,#245): E8 — Stores pages (list, detail, query)
This commit is contained in:
commit
ed50756ccd
7 changed files with 1343 additions and 21 deletions
57
frontend/src/features/store/api.test.ts
Normal file
57
frontend/src/features/store/api.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { fetchStores, fetchStoreDocuments, removeDocumentFromStore, queryStore } from './api'
|
||||||
|
|
||||||
|
vi.mock('../../shared/api/http', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
|
||||||
|
describe('store API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchStores calls GET /api/stores', async () => {
|
||||||
|
const stores = [{ name: 'my-store', type: 'opensearch', connected: true }]
|
||||||
|
apiFetch.mockResolvedValue(stores)
|
||||||
|
|
||||||
|
const result = await fetchStores()
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/stores')
|
||||||
|
expect(result).toEqual(stores)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchStoreDocuments calls GET /api/stores/:store/documents', async () => {
|
||||||
|
const docs = [{ docId: 'doc-1', filename: 'test.pdf', state: 'Ingested', chunkCount: 12 }]
|
||||||
|
apiFetch.mockResolvedValue(docs)
|
||||||
|
|
||||||
|
const result = await fetchStoreDocuments('my-store')
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/documents')
|
||||||
|
expect(result).toEqual(docs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDocumentFromStore calls DELETE /api/stores/:store/documents/:docId', async () => {
|
||||||
|
apiFetch.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await removeDocumentFromStore('my-store', 'doc-1')
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/documents/doc-1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queryStore calls POST /api/stores/:store/query with body', async () => {
|
||||||
|
const results = [{ chunkId: 'c1', docId: 'd1', filename: 'a.pdf', text: 'hi', score: 0.9 }]
|
||||||
|
apiFetch.mockResolvedValue(results)
|
||||||
|
|
||||||
|
const result = await queryStore('my-store', 'what is X?', 3)
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith('/api/stores/my-store/query', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query: 'what is X?', top_k: 3 }),
|
||||||
|
})
|
||||||
|
expect(result).toEqual(results)
|
||||||
|
})
|
||||||
|
})
|
||||||
50
frontend/src/features/store/api.ts
Normal file
50
frontend/src/features/store/api.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
import type { DocumentLifecycleState } from '../../shared/types'
|
||||||
|
|
||||||
|
export interface StoreInfo {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
connected: boolean
|
||||||
|
documentCount: number
|
||||||
|
chunkCount: number
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreDocEntry {
|
||||||
|
docId: string
|
||||||
|
filename: string
|
||||||
|
state: DocumentLifecycleState
|
||||||
|
chunkCount: number
|
||||||
|
pushedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult {
|
||||||
|
chunkId: string
|
||||||
|
docId: string
|
||||||
|
filename: string
|
||||||
|
text: string
|
||||||
|
score: number
|
||||||
|
pageRange?: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchStores(): Promise<StoreInfo[]> {
|
||||||
|
return apiFetch<StoreInfo[]>('/api/stores')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchStoreDocuments(store: string): Promise<StoreDocEntry[]> {
|
||||||
|
return apiFetch<StoreDocEntry[]>(`/api/stores/${encodeURIComponent(store)}/documents`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDocumentFromStore(store: string, docId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(
|
||||||
|
`/api/stores/${encodeURIComponent(store)}/documents/${encodeURIComponent(docId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryStore(store: string, query: string, topK = 5): Promise<QueryResult[]> {
|
||||||
|
return apiFetch<QueryResult[]>(`/api/stores/${encodeURIComponent(store)}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query, top_k: topK }),
|
||||||
|
})
|
||||||
|
}
|
||||||
1
frontend/src/features/store/index.ts
Normal file
1
frontend/src/features/store/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './api'
|
||||||
|
|
@ -1,20 +1,468 @@
|
||||||
<template>
|
<template>
|
||||||
<ComingSoonShell
|
<div class="detail-page">
|
||||||
:title="t('comingSoon.title')"
|
<div class="detail-header">
|
||||||
:subtitle="t('comingSoon.subtitle.storeDetail')"
|
<RouterLink :to="{ name: ROUTES.STORES_LIST }" class="back-link">
|
||||||
:hint="hint"
|
← {{ t('storeDetail.back') }}
|
||||||
/>
|
</RouterLink>
|
||||||
|
<h1 class="detail-title">{{ store }}</h1>
|
||||||
|
<RouterLink :to="{ name: ROUTES.STORE_QUERY, params: { store } }" class="btn-primary">
|
||||||
|
{{ t('storeDetail.query') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<p class="error-text">{{ error }}</p>
|
||||||
|
<button class="btn-secondary" @click="load">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="docs.length" class="table-wrapper">
|
||||||
|
<table class="detail-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="allSelected"
|
||||||
|
:indeterminate="someSelected"
|
||||||
|
@change="toggleAll"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>{{ t('storeDetail.colDoc') }}</th>
|
||||||
|
<th>{{ t('storeDetail.colState') }}</th>
|
||||||
|
<th class="col-num">{{ t('storeDetail.colChunks') }}</th>
|
||||||
|
<th>{{ t('storeDetail.colIngested') }}</th>
|
||||||
|
<th class="col-action" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="doc in docs" :key="doc.docId" class="doc-row" @click="openDoc(doc.docId)">
|
||||||
|
<td class="col-check" @click.stop>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.has(doc.docId)"
|
||||||
|
@change="toggleDoc(doc.docId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="col-name">
|
||||||
|
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="doc-filename" :title="doc.filename">{{ doc.filename }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge :state="doc.state" />
|
||||||
|
</td>
|
||||||
|
<td class="col-num">{{ doc.chunkCount }}</td>
|
||||||
|
<td class="col-date">{{ doc.pushedAt ? formatDate(doc.pushedAt) : '—' }}</td>
|
||||||
|
<td class="col-action" @click.stop>
|
||||||
|
<button class="btn-sm btn-sm--danger" @click="removeDoc(doc)">
|
||||||
|
{{ t('storeDetail.remove') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p class="empty-title">{{ t('storeDetail.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk action bar -->
|
||||||
|
<div v-if="selectedIds.size > 0" class="bulk-bar">
|
||||||
|
<span class="bulk-count">{{ t('storeDetail.selected', { n: selectedIds.size }) }}</span>
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<button class="btn-sm btn-sm--danger" @click="bulkRemove">
|
||||||
|
{{ t('storeDetail.bulkRemove') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm btn-sm--ghost" @click="clearSelection">
|
||||||
|
{{ t('storeDetail.bulkCancel') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
import {
|
||||||
|
fetchStoreDocuments,
|
||||||
|
removeDocumentFromStore,
|
||||||
|
type StoreDocEntry,
|
||||||
|
} from '../features/store/api'
|
||||||
|
import StatusBadge from '../features/document/ui/StatusBadge.vue'
|
||||||
|
import { useI18n } from '../shared/i18n'
|
||||||
|
import { ROUTES } from '../shared/routing/names'
|
||||||
|
import { appLocale } from '../shared/appConfig'
|
||||||
|
|
||||||
const props = defineProps<{ store: string }>()
|
const props = defineProps<{ store: string }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hint = computed(() => t('comingSoon.hint.storeDetail', { store: props.store }))
|
const docs = ref<StoreDocEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const selectedIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
docs.value = await fetchStoreDocuments(props.store)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(appLocale.value === 'fr' ? 'fr-FR' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDoc(docId: string): void {
|
||||||
|
router.push({ name: ROUTES.DOC_WORKSPACE, params: { id: docId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDoc(doc: StoreDocEntry): Promise<void> {
|
||||||
|
if (!window.confirm(t('storeDetail.removeConfirm', { doc: doc.filename }))) return
|
||||||
|
await removeDocumentFromStore(props.store, doc.docId)
|
||||||
|
docs.value = docs.value.filter((d) => d.docId !== doc.docId)
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
next.delete(doc.docId)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = computed(
|
||||||
|
() => docs.value.length > 0 && docs.value.every((d) => selectedIds.value.has(d.docId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const someSelected = computed(
|
||||||
|
() => docs.value.some((d) => selectedIds.value.has(d.docId)) && !allSelected.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleDoc(id: string): void {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(): void {
|
||||||
|
if (allSelected.value) {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
} else {
|
||||||
|
selectedIds.value = new Set(docs.value.map((d) => d.docId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkRemove(): Promise<void> {
|
||||||
|
const n = selectedIds.value.size
|
||||||
|
if (!window.confirm(t('storeDetail.bulkConfirm', { n }))) return
|
||||||
|
const ids = [...selectedIds.value]
|
||||||
|
clearSelection()
|
||||||
|
await Promise.all(ids.map((id) => removeDocumentFromStore(props.store, id)))
|
||||||
|
docs.value = docs.value.filter((d) => !ids.includes(d.docId))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-check {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-num {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table td.col-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-action {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-row {
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table td {
|
||||||
|
padding: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-filename {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-date {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-count {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm--danger {
|
||||||
|
color: var(--error);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm--ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,389 @@
|
||||||
<template>
|
<template>
|
||||||
<ComingSoonShell
|
<div class="query-page">
|
||||||
:title="t('comingSoon.title')"
|
<div class="query-header">
|
||||||
:subtitle="t('comingSoon.subtitle.storeQuery')"
|
<RouterLink :to="{ name: ROUTES.STORE_DETAIL, params: { store } }" class="back-link">
|
||||||
:hint="hint"
|
← {{ t('storeQuery.back') }}
|
||||||
/>
|
</RouterLink>
|
||||||
|
<h1 class="query-title">{{ store }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="query-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="form-label" for="query-input">{{ t('storeQuery.queryLabel') }}</label>
|
||||||
|
<div class="query-input-group">
|
||||||
|
<textarea
|
||||||
|
id="query-input"
|
||||||
|
v-model="queryText"
|
||||||
|
class="query-textarea"
|
||||||
|
:placeholder="t('storeQuery.queryPlaceholder')"
|
||||||
|
rows="3"
|
||||||
|
@keydown.ctrl.enter="run"
|
||||||
|
@keydown.meta.enter="run"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" :disabled="running || !queryText.trim()" @click="run">
|
||||||
|
{{ running ? t('storeQuery.running') : t('storeQuery.run') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row form-row--inline">
|
||||||
|
<label class="form-label" for="topk-input">{{ t('storeQuery.topKLabel') }}</label>
|
||||||
|
<input
|
||||||
|
id="topk-input"
|
||||||
|
v-model.number="topK"
|
||||||
|
type="number"
|
||||||
|
class="topk-input"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="results !== null" class="results-section">
|
||||||
|
<div v-if="results.length === 0" class="empty-results">
|
||||||
|
{{ t('storeQuery.empty') }}
|
||||||
|
</div>
|
||||||
|
<table v-else class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-score">{{ t('storeQuery.colScore') }}</th>
|
||||||
|
<th>{{ t('storeQuery.colDoc') }}</th>
|
||||||
|
<th>{{ t('storeQuery.colText') }}</th>
|
||||||
|
<th class="col-page">{{ t('storeQuery.colPage') }}</th>
|
||||||
|
<th class="col-view" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(r, idx) in results" :key="idx" class="result-row">
|
||||||
|
<td class="col-score">
|
||||||
|
<span class="score-badge">{{ r.score.toFixed(3) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-doc">
|
||||||
|
<span class="doc-name" :title="r.filename">{{ r.filename }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-text">
|
||||||
|
<span class="excerpt">{{ r.text }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-page">
|
||||||
|
<span v-if="r.pageRange" class="page-range">
|
||||||
|
{{ r.pageRange[0] }}–{{ r.pageRange[1] }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="no-value">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-view">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: ROUTES.DOC_WORKSPACE, params: { id: r.docId } }"
|
||||||
|
class="view-link"
|
||||||
|
>
|
||||||
|
{{ t('storeQuery.viewDoc') }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
import { queryStore, type QueryResult } from '../features/store/api'
|
||||||
|
import { useI18n } from '../shared/i18n'
|
||||||
|
import { ROUTES } from '../shared/routing/names'
|
||||||
|
|
||||||
const props = defineProps<{ store: string }>()
|
const props = defineProps<{ store: string }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hint = computed(() => t('comingSoon.hint.storeQuery', { store: props.store }))
|
const queryText = ref('')
|
||||||
|
const topK = ref(5)
|
||||||
|
const running = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const results = ref<QueryResult[] | null>(null)
|
||||||
|
|
||||||
|
async function run(): Promise<void> {
|
||||||
|
const q = queryText.value.trim()
|
||||||
|
if (!q || running.value) return
|
||||||
|
running.value = true
|
||||||
|
error.value = null
|
||||||
|
results.value = null
|
||||||
|
try {
|
||||||
|
results.value = await queryStore(props.store, q, topK.value)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.query-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row--inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topk-input {
|
||||||
|
width: 80px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topk-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: 12px 24px 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-results {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-score {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-page {
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table td.col-page {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-view {
|
||||||
|
width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table td {
|
||||||
|
padding: 12px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent-muted);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-doc {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excerpt {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-range {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-value {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,326 @@
|
||||||
<template>
|
<template>
|
||||||
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.stores')" />
|
<div class="stores-page">
|
||||||
|
<div class="stores-header">
|
||||||
|
<h1 class="stores-title">{{ t('stores.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="error-state">
|
||||||
|
<p class="error-text">{{ error }}</p>
|
||||||
|
<button class="btn-secondary" @click="load">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="stores.length" class="table-wrapper">
|
||||||
|
<table class="stores-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('stores.colName') }}</th>
|
||||||
|
<th>{{ t('stores.colType') }}</th>
|
||||||
|
<th>{{ t('stores.colStatus') }}</th>
|
||||||
|
<th class="col-num">{{ t('stores.colDocs') }}</th>
|
||||||
|
<th class="col-num">{{ t('stores.colChunks') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="store in stores"
|
||||||
|
:key="store.name"
|
||||||
|
class="store-row"
|
||||||
|
@click="openStore(store.name)"
|
||||||
|
>
|
||||||
|
<td class="col-name">
|
||||||
|
<svg class="store-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M3 12v3c0 1.657 3.134 3 7 3s7-1.343 7-3v-3c0 1.657-3.134 3-7 3s-7-1.343-7-3z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 7v3c0 1.657 3.134 3 7 3s7-1.343 7-3V7c0 1.657-3.134 3-7 3S3 8.657 3 7z"
|
||||||
|
/>
|
||||||
|
<path d="M17 5c0 1.657-3.134 3-7 3S3 6.657 3 5s3.134-3 7-3 7 1.343 7 3z" />
|
||||||
|
</svg>
|
||||||
|
<span class="store-name">{{ store.name }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="store-type">{{ store.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
:class="store.connected ? 'status-badge--ok' : 'status-badge--err'"
|
||||||
|
>
|
||||||
|
{{ store.connected ? t('stores.connected') : t('stores.disconnected') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="store.errorMessage" class="error-hint" :title="store.errorMessage">
|
||||||
|
{{ store.errorMessage }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-num">{{ store.documentCount }}</td>
|
||||||
|
<td class="col-num">{{ store.chunkCount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<svg
|
||||||
|
class="empty-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
>
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||||
|
<path d="M21 12c0 1.657-4.03 3-9 3S3 13.657 3 12" />
|
||||||
|
<path d="M3 5v14c0 1.657 4.03 3 9 3s9-1.343 9-3V5" />
|
||||||
|
</svg>
|
||||||
|
<p class="empty-title">{{ t('stores.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchStores, type StoreInfo } from '../features/store/api'
|
||||||
import { useI18n } from '../shared/i18n'
|
import { useI18n } from '../shared/i18n'
|
||||||
|
import { ROUTES } from '../shared/routing/names'
|
||||||
|
|
||||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
const router = useRouter()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const stores = ref<StoreInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
stores.value = await fetchStores()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStore(name: string): void {
|
||||||
|
router.push({ name: ROUTES.STORE_DETAIL, params: { store: name } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stores-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-table th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-num {
|
||||||
|
width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-table td.col-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-row {
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-table td {
|
||||||
|
padding: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-type {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--ok {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--err {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--error);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,47 @@ const messages: Messages = {
|
||||||
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
|
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
|
||||||
'chunks.bulkCancel': 'Annuler',
|
'chunks.bulkCancel': 'Annuler',
|
||||||
|
|
||||||
|
// Stores list (#243)
|
||||||
|
'stores.title': 'Stores',
|
||||||
|
'stores.empty': 'Aucun store configur\u00e9.',
|
||||||
|
'stores.colName': 'Nom',
|
||||||
|
'stores.colType': 'Type',
|
||||||
|
'stores.colStatus': 'Statut',
|
||||||
|
'stores.colDocs': 'Documents',
|
||||||
|
'stores.colChunks': 'Chunks',
|
||||||
|
'stores.connected': 'Connect\u00e9',
|
||||||
|
'stores.disconnected': 'D\u00e9connect\u00e9',
|
||||||
|
'stores.error': 'Erreur',
|
||||||
|
|
||||||
|
// Store detail (#244)
|
||||||
|
'storeDetail.back': 'Stores',
|
||||||
|
'storeDetail.query': 'Requ\u00eater',
|
||||||
|
'storeDetail.empty': 'Aucun document dans ce store.',
|
||||||
|
'storeDetail.colDoc': 'Document',
|
||||||
|
'storeDetail.colState': '\u00c9tat',
|
||||||
|
'storeDetail.colChunks': 'Chunks',
|
||||||
|
'storeDetail.colIngested': 'Ing\u00e9r\u00e9 le',
|
||||||
|
'storeDetail.remove': 'Retirer',
|
||||||
|
'storeDetail.removeConfirm': 'Retirer \u00ab\u00a0{doc}\u00a0\u00bb du store\u00a0?',
|
||||||
|
'storeDetail.selected': '{n} s\u00e9lectionn\u00e9(s)',
|
||||||
|
'storeDetail.bulkRemove': 'Retirer du store',
|
||||||
|
'storeDetail.bulkCancel': 'Annuler',
|
||||||
|
'storeDetail.bulkConfirm': 'Retirer {n} document(s) du store\u00a0?',
|
||||||
|
|
||||||
|
// Store query (#245)
|
||||||
|
'storeQuery.back': 'D\u00e9tail du store',
|
||||||
|
'storeQuery.queryLabel': 'Question',
|
||||||
|
'storeQuery.queryPlaceholder': 'Votre question\u2026',
|
||||||
|
'storeQuery.topKLabel': 'R\u00e9sultats (top-k)',
|
||||||
|
'storeQuery.run': 'Rechercher',
|
||||||
|
'storeQuery.running': 'Recherche\u2026',
|
||||||
|
'storeQuery.empty': 'Aucun r\u00e9sultat.',
|
||||||
|
'storeQuery.colScore': 'Score',
|
||||||
|
'storeQuery.colDoc': 'Document',
|
||||||
|
'storeQuery.colText': 'Extrait',
|
||||||
|
'storeQuery.colPage': 'Pages',
|
||||||
|
'storeQuery.viewDoc': 'Voir',
|
||||||
|
|
||||||
// Disclaimer
|
// Disclaimer
|
||||||
'disclaimer.banner':
|
'disclaimer.banner':
|
||||||
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||||
|
|
@ -855,6 +896,47 @@ const messages: Messages = {
|
||||||
'chunks.bulkMerge': 'Merge selected',
|
'chunks.bulkMerge': 'Merge selected',
|
||||||
'chunks.bulkCancel': 'Cancel',
|
'chunks.bulkCancel': 'Cancel',
|
||||||
|
|
||||||
|
// Stores list (#243)
|
||||||
|
'stores.title': 'Stores',
|
||||||
|
'stores.empty': 'No stores configured.',
|
||||||
|
'stores.colName': 'Name',
|
||||||
|
'stores.colType': 'Type',
|
||||||
|
'stores.colStatus': 'Status',
|
||||||
|
'stores.colDocs': 'Documents',
|
||||||
|
'stores.colChunks': 'Chunks',
|
||||||
|
'stores.connected': 'Connected',
|
||||||
|
'stores.disconnected': 'Disconnected',
|
||||||
|
'stores.error': 'Error',
|
||||||
|
|
||||||
|
// Store detail (#244)
|
||||||
|
'storeDetail.back': 'Stores',
|
||||||
|
'storeDetail.query': 'Query',
|
||||||
|
'storeDetail.empty': 'No documents in this store.',
|
||||||
|
'storeDetail.colDoc': 'Document',
|
||||||
|
'storeDetail.colState': 'State',
|
||||||
|
'storeDetail.colChunks': 'Chunks',
|
||||||
|
'storeDetail.colIngested': 'Ingested',
|
||||||
|
'storeDetail.remove': 'Remove',
|
||||||
|
'storeDetail.removeConfirm': 'Remove \u00ab{doc}\u00bb from store?',
|
||||||
|
'storeDetail.selected': '{n} selected',
|
||||||
|
'storeDetail.bulkRemove': 'Remove from store',
|
||||||
|
'storeDetail.bulkCancel': 'Cancel',
|
||||||
|
'storeDetail.bulkConfirm': 'Remove {n} document(s) from store?',
|
||||||
|
|
||||||
|
// Store query (#245)
|
||||||
|
'storeQuery.back': 'Store detail',
|
||||||
|
'storeQuery.queryLabel': 'Question',
|
||||||
|
'storeQuery.queryPlaceholder': 'Your question\u2026',
|
||||||
|
'storeQuery.topKLabel': 'Results (top-k)',
|
||||||
|
'storeQuery.run': 'Search',
|
||||||
|
'storeQuery.running': 'Searching\u2026',
|
||||||
|
'storeQuery.empty': 'No results.',
|
||||||
|
'storeQuery.colScore': 'Score',
|
||||||
|
'storeQuery.colDoc': 'Document',
|
||||||
|
'storeQuery.colText': 'Excerpt',
|
||||||
|
'storeQuery.colPage': 'Pages',
|
||||||
|
'storeQuery.viewDoc': 'View',
|
||||||
|
|
||||||
// Disclaimer
|
// Disclaimer
|
||||||
'disclaimer.banner':
|
'disclaimer.banner':
|
||||||
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',
|
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue