feat(#243,#244,#245): E8 — Stores pages (list, detail, query)

- features/store: api + types (StoreInfo, StoreDocEntry, QueryResult)
- StoresListPage: table with connection status, click → StoreDetail
- StoreDetailPage: doc table with StatusBadge, remove, bulk remove, link to query
- StoreQueryPage: query form (Ctrl+Enter), topK, scored results with doc links
- i18n: stores.* / storeDetail.* / storeQuery.* keys (FR + EN)

Closes #243
Closes #244
Closes #245
This commit is contained in:
Pier-Jean Malandrino 2026-04-30 11:07:16 +02:00
parent aae1e1a845
commit 66f0d0cf62
7 changed files with 1343 additions and 21 deletions

View 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)
})
})

View 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 }),
})
}

View file

@ -0,0 +1 @@
export * from './api'

View file

@ -1,20 +1,468 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeDetail')"
:hint="hint"
/>
<div class="detail-page">
<div class="detail-header">
<RouterLink :to="{ name: ROUTES.STORES_LIST }" class="back-link">
{{ 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>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import { computed, onMounted, ref } from 'vue'
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 router = useRouter()
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>
<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>

View file

@ -1,20 +1,389 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeQuery')"
:hint="hint"
/>
<div class="query-page">
<div class="query-header">
<RouterLink :to="{ name: ROUTES.STORE_DETAIL, params: { store } }" class="back-link">
{{ 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>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import { ref } from 'vue'
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 { 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>
<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>

View file

@ -1,11 +1,326 @@
<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>
<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 { ROUTES } from '../shared/routing/names'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const router = useRouter()
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>
<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>

View file

@ -437,6 +437,47 @@ const messages: Messages = {
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
'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.banner':
'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.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.banner':
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',