Expand the database catalog and make knowledge spaces easier to start.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b17782eec2
commit
e9171be229
20 files changed
+1619
-483
No files matched your search
@@ -1,33 +1,37 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export function SegmentedTabs<T extends string>({
|
export function SegmentedTabs<T extends string>({
|
||||||
items,
|
items,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className = '',
|
className = '',
|
||||||
}: {
|
}: {
|
||||||
items: ReadonlyArray<readonly [T, string]>
|
items: ReadonlyArray<{ id: T; label: string; icon?: LucideIcon }>
|
||||||
value: T
|
value: T
|
||||||
onChange: (value: T) => void
|
onChange: (value: T) => void
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`inline-flex gap-1 rounded-xl border border-line bg-surface-subtle p-1 ${className}`}
|
className={`inline-flex max-w-full gap-1 overflow-x-auto rounded-2xl border border-line bg-panel p-1 shadow-card ${className}`}
|
||||||
>
|
>
|
||||||
{items.map(([id, label]) => {
|
{items.map((item) => {
|
||||||
const active = value === id
|
const Icon = item.icon
|
||||||
|
const active = value === item.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(item.id)}
|
||||||
className={`rounded-lg px-3.5 py-1.5 text-sm transition ${
|
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm whitespace-nowrap transition ${
|
||||||
active
|
active
|
||||||
? 'bg-panel font-medium text-ink shadow-sm'
|
? 'bg-accent-soft font-medium text-accent'
|
||||||
: 'text-ink-muted hover:text-ink'
|
: 'text-ink-muted hover:bg-surface-subtle hover:text-ink'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||||
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function SelectMenu({
|
|||||||
const [highlight, setHighlight] = useState(-1)
|
const [highlight, setHighlight] = useState(-1)
|
||||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const scrollRequest = useRef<number | null>(null)
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => options.find((option) => option.value === value) || null,
|
() => options.find((option) => option.value === value) || null,
|
||||||
@@ -88,6 +90,12 @@ export function SelectMenu({
|
|||||||
() => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0),
|
() => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0),
|
||||||
[options],
|
[options],
|
||||||
)
|
)
|
||||||
|
const optionsRef = useRef(options)
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
const enabledRef = useRef(enabledIndexes)
|
||||||
|
optionsRef.current = options
|
||||||
|
valueRef.current = value
|
||||||
|
enabledRef.current = enabledIndexes
|
||||||
|
|
||||||
function updatePosition() {
|
function updatePosition() {
|
||||||
if (!triggerRef.current) return
|
if (!triggerRef.current) return
|
||||||
@@ -110,12 +118,33 @@ export function SelectMenu({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealHighlight(index: number) {
|
||||||
|
const list = listRef.current
|
||||||
|
const item = list?.querySelector<HTMLElement>(`[data-index="${index}"]`)
|
||||||
|
if (!list || !item) return
|
||||||
|
const listRect = list.getBoundingClientRect()
|
||||||
|
const itemRect = item.getBoundingClientRect()
|
||||||
|
if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top
|
||||||
|
else if (itemRect.bottom > listRect.bottom) list.scrollTop += itemRect.bottom - listRect.bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHighlight(index: number) {
|
||||||
|
scrollRequest.current = index
|
||||||
|
setHighlight(index)
|
||||||
|
}
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
scrollRequest.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
updatePosition()
|
updatePosition()
|
||||||
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled)
|
const selectedIndex = optionsRef.current.findIndex(
|
||||||
setHighlight(selectedIndex >= 0 ? selectedIndex : enabledIndexes[0] ?? -1)
|
(option) => option.value === valueRef.current && !option.disabled,
|
||||||
}, [open, options, value, enabledIndexes])
|
)
|
||||||
|
const enabled = enabledRef.current
|
||||||
|
requestHighlight(selectedIndex >= 0 ? selectedIndex : enabled[0] ?? -1)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -132,7 +161,9 @@ export function SelectMenu({
|
|||||||
triggerRef.current?.focus()
|
triggerRef.current?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onReposition() {
|
function onReposition(event: Event) {
|
||||||
|
const target = event.target
|
||||||
|
if (target instanceof Node && menuRef.current?.contains(target)) return
|
||||||
updatePosition()
|
updatePosition()
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onPointerDown)
|
document.addEventListener('mousedown', onPointerDown)
|
||||||
@@ -147,10 +178,10 @@ export function SelectMenu({
|
|||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open || highlight < 0) return
|
if (!open || highlight < 0 || scrollRequest.current !== highlight) return
|
||||||
const item = menuRef.current?.querySelector<HTMLElement>(`[data-index="${highlight}"]`)
|
scrollRequest.current = null
|
||||||
item?.scrollIntoView({ block: 'nearest' })
|
revealHighlight(highlight)
|
||||||
}, [highlight, open])
|
}, [highlight, open])
|
||||||
|
|
||||||
function selectValue(next: string) {
|
function selectValue(next: string) {
|
||||||
@@ -165,13 +196,13 @@ export function SelectMenu({
|
|||||||
const currentPos = enabledIndexes.indexOf(highlight)
|
const currentPos = enabledIndexes.indexOf(highlight)
|
||||||
const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0
|
const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0
|
||||||
const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length
|
const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length
|
||||||
setHighlight(enabledIndexes[nextPos]!)
|
requestHighlight(enabledIndexes[nextPos]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
menuRef.current?.focus()
|
menuRef.current?.focus({ preventScroll: true })
|
||||||
}, [open, pos])
|
}, [open])
|
||||||
|
|
||||||
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
|
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
@@ -194,12 +225,12 @@ export function SelectMenu({
|
|||||||
}
|
}
|
||||||
if (event.key === 'Home') {
|
if (event.key === 'Home') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setHighlight(enabledIndexes[0] ?? -1)
|
requestHighlight(enabledIndexes[0] ?? -1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === 'End') {
|
if (event.key === 'End') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1)
|
requestHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -236,7 +267,7 @@ export function SelectMenu({
|
|||||||
}}
|
}}
|
||||||
className={`fixed z-[9999] flex flex-col ${PANEL_CLASS}`}
|
className={`fixed z-[9999] flex flex-col ${PANEL_CLASS}`}
|
||||||
>
|
>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-ink-muted">{emptyLabel}</p>
|
<p className="px-3 py-6 text-center text-sm text-ink-muted">{emptyLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -266,7 +297,9 @@ export function SelectMenu({
|
|||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!option.disabled) setHighlight(index)
|
if (option.disabled) return
|
||||||
|
scrollRequest.current = null
|
||||||
|
setHighlight(index)
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!option.disabled) selectValue(option.value)
|
if (!option.disabled) selectValue(option.value)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
@@ -102,6 +103,50 @@ describe('ResourcePicker', () => {
|
|||||||
expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
|
expect(screen.getByRole('listbox', { name: 'داده' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the file list scrolled when another file is selected', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
function Harness() {
|
||||||
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([])
|
||||||
|
return (
|
||||||
|
<ResourcePicker
|
||||||
|
databases={[]}
|
||||||
|
knowledge={[]}
|
||||||
|
selectedDbs={[]}
|
||||||
|
selectedKnowledge={null}
|
||||||
|
onSelectDbs={vi.fn()}
|
||||||
|
onSelectKnowledge={vi.fn()}
|
||||||
|
libraryFiles={[book, sales]}
|
||||||
|
attachedFileIds={attachedFileIds}
|
||||||
|
onToggleLibraryFile={(file) =>
|
||||||
|
setAttachedFileIds((current) =>
|
||||||
|
current.includes(file.id) ? current.filter((id) => id !== file.id) : [...current, file.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = HTMLElement.prototype.scrollIntoView
|
||||||
|
HTMLElement.prototype.scrollIntoView = function scrollIntoView() {
|
||||||
|
const scroller = (this as HTMLElement).closest('.overflow-y-auto')
|
||||||
|
if (scroller instanceof HTMLElement) scroller.scrollTop = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<Harness />)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'داده' }))
|
||||||
|
const list = document.querySelector('.overflow-y-auto')
|
||||||
|
expect(list).toBeInstanceOf(HTMLElement)
|
||||||
|
if (!(list instanceof HTMLElement)) return
|
||||||
|
list.scrollTop = 140
|
||||||
|
await user.click(screen.getByRole('option', { name: /sales/ }))
|
||||||
|
expect(list.scrollTop).toBe(140)
|
||||||
|
expect(screen.getByRole('option', { name: /sales/ })).toHaveAttribute('aria-selected', 'true')
|
||||||
|
} finally {
|
||||||
|
HTMLElement.prototype.scrollIntoView = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('attaches an active business connector', async () => {
|
it('attaches an active business connector', async () => {
|
||||||
const onSelectConnectors = vi.fn()
|
const onSelectConnectors = vi.fn()
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ const APP_PREFIX = 'app:'
|
|||||||
function databaseLabel(type?: string) {
|
function databaseLabel(type?: string) {
|
||||||
if (!type) return t('assistant.composer.dataConnection')
|
if (!type) return t('assistant.composer.dataConnection')
|
||||||
const id = type === 'postgres' ? 'postgresql' : type
|
const id = type === 'postgres' ? 'postgresql' : type
|
||||||
return ENGINES.find((engine) => engine.id === id)?.label || type
|
return (
|
||||||
|
ENGINES.find((engine) => engine.id === id || engine.id.toLowerCase() === id.toLowerCase())?.label ||
|
||||||
|
type
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function appLabel(type?: string) {
|
function appLabel(type?: string) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ConnectionsPage } from './ConnectionsPage'
|
||||||
|
|
||||||
|
vi.mock('../auth/AuthContext', () => ({
|
||||||
|
useAuth: () => ({ user: { role: 'admin', id: 'usr_1' } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../databases/api', () => ({
|
||||||
|
listDatasourceRecords: vi.fn(),
|
||||||
|
deleteDatasource: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { listDatasourceRecords } from '../databases/api'
|
||||||
|
|
||||||
|
describe('ConnectionsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listDatasourceRecords).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'ds_1',
|
||||||
|
name: 'banktala',
|
||||||
|
type: 'mongodb',
|
||||||
|
host: 'localhost',
|
||||||
|
database: 'test-ban',
|
||||||
|
config: { host: 'localhost', database: 'test-ban' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts the engine catalog on top and keeps saved connections full width', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ConnectionsPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'ClickHouse' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'DuckDB' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'MaxCompute' })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Neo4j' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'MongoDB' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('گراف و سند')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'همه' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('banktala')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,22 +1,54 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { Database, RefreshCw, Trash2 } from 'lucide-react'
|
import { Cable, Database, RefreshCw, Search, Trash2 } from 'lucide-react'
|
||||||
import { PageHeader } from '../../components/ui/PageHeader'
|
import { PageHeader } from '../../components/ui/PageHeader'
|
||||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||||
import { Badge } from '../../components/ui/StatusBadge'
|
import { Badge } from '../../components/ui/StatusBadge'
|
||||||
|
import { Input } from '../../components/ui/Field'
|
||||||
import { TableSkeleton } from '../../components/ui/Skeleton'
|
import { TableSkeleton } from '../../components/ui/Skeleton'
|
||||||
import { ConnectEngineModal } from '../databases/ConnectEngineModal'
|
import { ConnectEngineModal } from '../databases/ConnectEngineModal'
|
||||||
import { EngineMark } from '../databases/EngineMark'
|
import { EngineMark } from '../databases/EngineMark'
|
||||||
import { ENGINES, engineById, type EngineId } from '../databases/engines'
|
import { ENGINES, FILE_ENGINE_IDS, engineById, type EngineDef, type EngineId } from '../databases/engines'
|
||||||
import { deleteDatasource, listDatasourceRecords, type DatasourceRecord } from '../databases/api'
|
import { deleteDatasource, listDatasourceRecords, type DatasourceRecord } from '../databases/api'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
||||||
const GROUPS: Array<{ labelKey: string; ids: EngineId[] }> = [
|
type GroupId = 'sql' | 'warehouse' | 'file'
|
||||||
{ labelKey: 'dataHub.groupSql', ids: ['mysql', 'postgresql', 'oracle', 'mssql'] },
|
|
||||||
{ labelKey: 'dataHub.groupOther', ids: ['sqlite', 'neo4j', 'mongodb'] },
|
const GROUPS: Array<{ id: GroupId; labelKey: string; ids: EngineId[] }> = [
|
||||||
|
{
|
||||||
|
id: 'sql',
|
||||||
|
labelKey: 'dataHub.groupSql',
|
||||||
|
ids: ['mysql', 'mariadb', 'oceanbase', 'postgresql', 'oracle', 'mssql'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warehouse',
|
||||||
|
labelKey: 'dataHub.groupWarehouse',
|
||||||
|
ids: ['clickhouse', 'starrocks', 'doris', 'hive', 'gaussdb', 'openGauss', 'vertica', 'maxcompute'],
|
||||||
|
},
|
||||||
|
{ id: 'file', labelKey: 'dataHub.groupFile', ids: ['sqlite', 'duckdb', 'spark'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const OFFERED_ENGINES = new Set<EngineId>(GROUPS.flatMap((group) => group.ids))
|
||||||
|
|
||||||
|
function connectionMeta(item: DatasourceRecord) {
|
||||||
|
const engine = engineById(item.type)
|
||||||
|
const host = item.host || String(item.config?.host || item.config?.endpoint || '')
|
||||||
|
const database = item.database || String(
|
||||||
|
item.config?.database || item.config?.project || item.config?.path || '',
|
||||||
|
)
|
||||||
|
if (FILE_ENGINE_IDS.has(engine.id)) {
|
||||||
|
return String(item.config?.path || database || t('databases.kindFile'))
|
||||||
|
}
|
||||||
|
return [host, database].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineMatches(engine: EngineDef, query: string) {
|
||||||
|
if (!query) return true
|
||||||
|
const hay = `${engine.label} ${engine.id} ${t(engine.blurbKey)}`.toLowerCase()
|
||||||
|
return hay.includes(query)
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectionsPage() {
|
export function ConnectionsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const canManage = user?.role !== 'user'
|
const canManage = user?.role !== 'user'
|
||||||
@@ -26,6 +58,7 @@ export function ConnectionsPage() {
|
|||||||
const [deletingConnId, setDeletingConnId] = useState<string | null>(null)
|
const [deletingConnId, setDeletingConnId] = useState<string | null>(null)
|
||||||
const [connectEngine, setConnectEngine] = useState<EngineId | null>(null)
|
const [connectEngine, setConnectEngine] = useState<EngineId | null>(null)
|
||||||
const [editing, setEditing] = useState<DatasourceRecord | null>(null)
|
const [editing, setEditing] = useState<DatasourceRecord | null>(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const ordered = useMemo(
|
const ordered = useMemo(
|
||||||
@@ -33,6 +66,25 @@ export function ConnectionsPage() {
|
|||||||
[items],
|
[items],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countsByType = useMemo(() => {
|
||||||
|
const next = new Map<EngineId, number>()
|
||||||
|
for (const item of items) {
|
||||||
|
const id = engineById(item.type).id
|
||||||
|
next.set(id, (next.get(id) || 0) + 1)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const visibleGroups = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
return GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
engines: group.ids
|
||||||
|
.map((id) => ENGINES.find((item) => item.id === id) || engineById(id))
|
||||||
|
.filter((engine) => engineMatches(engine, needle)),
|
||||||
|
})).filter((group) => group.engines.length > 0)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -52,9 +104,12 @@ export function ConnectionsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requested = searchParams.get('engine')
|
const requested = searchParams.get('engine')
|
||||||
if (!canManage || !requested || !ENGINES.some((engine) => engine.id === requested)) return
|
const matched = requested
|
||||||
|
? ENGINES.find((item) => item.id === requested || item.id.toLowerCase() === requested.toLowerCase())
|
||||||
|
: undefined
|
||||||
|
if (!canManage || !matched || !OFFERED_ENGINES.has(matched.id)) return
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setConnectEngine(requested as EngineId)
|
setConnectEngine(matched.id)
|
||||||
const next = new URLSearchParams(searchParams)
|
const next = new URLSearchParams(searchParams)
|
||||||
next.delete('engine')
|
next.delete('engine')
|
||||||
setSearchParams(next, { replace: true })
|
setSearchParams(next, { replace: true })
|
||||||
@@ -72,14 +127,6 @@ export function ConnectionsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectionMeta(item: DatasourceRecord) {
|
|
||||||
const engine = engineById(item.type)
|
|
||||||
const host = item.host || String(item.config?.host || '')
|
|
||||||
const database = item.database || String(item.config?.database || item.config?.path || '')
|
|
||||||
if (engine.id === 'sqlite') return database || t('databases.kindFile')
|
|
||||||
return [host, database].filter(Boolean).join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEngine(id: EngineId) {
|
function openEngine(id: EngineId) {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setConnectEngine(id)
|
setConnectEngine(id)
|
||||||
@@ -110,97 +157,45 @@ export function ConnectionsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
|
<section className="mb-8">
|
||||||
<section className={`min-w-0 ${!loading && ordered.length === 0 ? 'order-2 lg:order-1' : ''}`}>
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h2 className="mb-3 text-sm font-semibold text-ink">{t('dataHub.savedTitle')}</h2>
|
<div className="min-w-0">
|
||||||
{loading ? (
|
<h2 className="text-sm font-semibold text-ink">{t('dataHub.addEngineTitle')}</h2>
|
||||||
<TableSkeleton rows={4} />
|
<p className="mt-1 text-[12px] leading-5 text-ink-muted">{t('dataHub.addEngineHint')}</p>
|
||||||
) : ordered.length === 0 ? (
|
{!canManage ? (
|
||||||
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-14 text-center shadow-card">
|
<p className="mt-1 text-[12px] leading-5 text-ink-muted">{t('dataHub.manageOnly')}</p>
|
||||||
<span className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent">
|
) : null}
|
||||||
<Cable className="h-5 w-5" />
|
</div>
|
||||||
</span>
|
<div className="w-full sm:w-52">
|
||||||
<h3 className="mt-4 text-base font-semibold text-ink">{t('dataHub.savedEmptyTitle')}</h3>
|
<Input
|
||||||
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-6 text-ink-muted">{t('dataHub.savedEmptyBody')}</p>
|
value={query}
|
||||||
</div>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
) : (
|
placeholder={t('dataHub.engineSearch')}
|
||||||
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
|
aria-label={t('dataHub.engineSearch')}
|
||||||
{ordered.map((item) => {
|
leadingIcon={<Search className="h-4 w-4" />}
|
||||||
const engine = engineById(item.type)
|
/>
|
||||||
return (
|
</div>
|
||||||
<li key={item.id} className="border-b border-line last:border-b-0">
|
</div>
|
||||||
<div
|
|
||||||
className={`flex items-center gap-3 px-4 py-3 ${
|
|
||||||
canManage ? 'cursor-pointer hover:bg-surface-subtle' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => canManage && setEditing(item)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (!canManage) return
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault()
|
|
||||||
setEditing(item)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role={canManage ? 'button' : undefined}
|
|
||||||
tabIndex={canManage ? 0 : undefined}
|
|
||||||
>
|
|
||||||
<EngineMark id={engine.id} className="h-10 w-10" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="truncate text-sm font-semibold text-ink">{item.name}</h3>
|
|
||||||
<Badge tone="accent">{engine.label}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate text-[12px] text-ink-muted" dir="ltr">
|
|
||||||
{connectionMeta(item)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canManage ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
|
||||||
aria-label={t('databases.deleteTitle')}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setDeletingConnId(item.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{ordered.length ? (
|
|
||||||
<p className="mt-4 text-sm">
|
|
||||||
<Link to="/data/map" className="font-medium text-accent hover:underline">
|
|
||||||
{t('dataHub.openMap')}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className={`lg:sticky lg:top-4 ${!loading && ordered.length === 0 ? 'order-1 lg:order-2' : ''}`}>
|
{visibleGroups.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-line bg-panel p-3 shadow-card">
|
<p className="rounded-2xl border border-dashed border-line bg-panel px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
<h2 className="px-2 pt-1 text-sm font-semibold text-ink">{t('dataHub.addEngineTitle')}</h2>
|
{t('dataHub.engineNoMatches')}
|
||||||
<p className="mt-1 px-2 text-[12px] leading-5 text-ink-muted">{t('dataHub.addEngineHint')}</p>
|
</p>
|
||||||
{!canManage ? <p className="mt-2 px-2 text-[12px] leading-5 text-ink-muted">{t('dataHub.manageOnly')}</p> : null}
|
) : (
|
||||||
{GROUPS.map((group) => (
|
<div className="space-y-5">
|
||||||
<div key={group.labelKey} className="mt-3">
|
{visibleGroups.map((group) => (
|
||||||
<p className="px-2 pb-1 text-[11px] font-semibold text-ink-subtle">{t(group.labelKey)}</p>
|
<div key={group.id}>
|
||||||
<div className="space-y-0.5">
|
<p className="mb-2 text-[11px] font-semibold text-ink-subtle">{t(group.labelKey)}</p>
|
||||||
{group.ids.map((id) => {
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
const engine = ENGINES.find((item) => item.id === id) || engineById(id)
|
{group.engines.map((engine) => {
|
||||||
const count = items.filter((item) => item.type === engine.id).length
|
const count = countsByType.get(engine.id) || 0
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={engine.id}
|
key={engine.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canManage}
|
disabled={!canManage}
|
||||||
onClick={() => openEngine(engine.id)}
|
onClick={() => openEngine(engine.id)}
|
||||||
className="flex w-full items-center gap-2.5 rounded-xl px-2 py-2 text-right transition hover:bg-surface-subtle disabled:cursor-not-allowed disabled:opacity-60"
|
className="flex min-h-12 items-center gap-2.5 rounded-2xl border border-line bg-panel px-2.5 py-2 text-right shadow-card transition hover:-translate-y-0.5 hover:border-line-strong disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0"
|
||||||
>
|
>
|
||||||
<EngineMark id={engine.id} className="h-9 w-9" />
|
<EngineMark id={engine.id} className="h-9 w-9" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -208,7 +203,6 @@ export function ConnectionsPage() {
|
|||||||
<span className="truncate text-[13px] font-semibold text-ink">{engine.label}</span>
|
<span className="truncate text-[13px] font-semibold text-ink">{engine.label}</span>
|
||||||
{count ? <Badge tone="accent">{count}</Badge> : null}
|
{count ? <Badge tone="accent">{count}</Badge> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[11px] text-ink-muted">{t(engine.blurbKey)}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -217,8 +211,79 @@ export function ConnectionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-ink">{t('dataHub.savedTitle')}</h2>
|
||||||
|
{loading ? (
|
||||||
|
<TableSkeleton rows={4} />
|
||||||
|
) : ordered.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-10 text-center shadow-card">
|
||||||
|
<span className="mx-auto inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-accent-soft text-accent">
|
||||||
|
<Cable className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-3 text-base font-semibold text-ink">{t('dataHub.savedEmptyTitle')}</h3>
|
||||||
|
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-6 text-ink-muted">{t('dataHub.savedEmptyBody')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
|
||||||
|
{ordered.map((item) => {
|
||||||
|
const engine = engineById(item.type)
|
||||||
|
return (
|
||||||
|
<li key={item.id} className="border-b border-line last:border-b-0">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 ${
|
||||||
|
canManage ? 'cursor-pointer hover:bg-surface-subtle' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => canManage && setEditing(item)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!canManage) return
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setEditing(item)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role={canManage ? 'button' : undefined}
|
||||||
|
tabIndex={canManage ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<EngineMark id={engine.id} className="h-10 w-10" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-ink">{item.name}</h3>
|
||||||
|
<Badge tone="accent">{engine.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-[12px] text-ink-muted" dir="ltr">
|
||||||
|
{connectionMeta(item)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||||
|
aria-label={t('databases.deleteTitle')}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setDeletingConnId(item.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{ordered.length ? (
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
<Link to="/data/map" className="font-medium text-accent hover:underline">
|
||||||
|
{t('dataHub.openMap')}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
<ConnectEngineModal
|
<ConnectEngineModal
|
||||||
open={Boolean(connectEngine) || Boolean(editing)}
|
open={Boolean(connectEngine) || Boolean(editing)}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Braces, Cable, Database, FileSpreadsheet, LayoutDashboard, Network, Table2 } from 'lucide-react'
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Braces,
|
||||||
|
Cable,
|
||||||
|
Database,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Files,
|
||||||
|
Layers,
|
||||||
|
LayoutDashboard,
|
||||||
|
Network,
|
||||||
|
Table2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { PageHeader } from '../../components/ui/PageHeader'
|
import { PageHeader } from '../../components/ui/PageHeader'
|
||||||
import { CardGridSkeleton } from '../../components/ui/Skeleton'
|
import { CardGridSkeleton } from '../../components/ui/Skeleton'
|
||||||
import { listWorkspaceFiles, type KamidsoFile } from '../assistant/session-files'
|
import { listWorkspaceFiles, type KamidsoFile } from '../assistant/session-files'
|
||||||
import { isDataFileKind, isDataFilename } from '../assistant/session-files/fileKind'
|
import { isDataFileKind, isDataFilename, kindFromFilename } from '../assistant/session-files/fileKind'
|
||||||
import { listConnectors, type ConnectorInstance } from '../connectors/api'
|
import { listConnectors, type ConnectorInstance } from '../connectors/api'
|
||||||
import { DidarMark } from '../connectors/DidarMark'
|
import { DidarMark } from '../connectors/DidarMark'
|
||||||
import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
|
import { listDatasourceRecords, type DatasourceRecord } from '../databases/api'
|
||||||
@@ -20,7 +32,16 @@ const FILE_SUGGESTIONS = [
|
|||||||
{ id: 'json', label: 'JSON', hintKey: 'dataHub.hintJson', icon: Braces, wrap: 'bg-amber-50 text-amber-800' },
|
{ id: 'json', label: 'JSON', hintKey: 'dataHub.hintJson', icon: Braces, wrap: 'bg-amber-50 text-amber-800' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const DATABASE_SUGGESTIONS: EngineId[] = ['mysql', 'postgresql', 'mssql', 'oracle']
|
const DATABASE_SUGGESTIONS: EngineId[] = [
|
||||||
|
'mysql',
|
||||||
|
'mariadb',
|
||||||
|
'postgresql',
|
||||||
|
'oracle',
|
||||||
|
'mssql',
|
||||||
|
'clickhouse',
|
||||||
|
'sqlite',
|
||||||
|
'duckdb',
|
||||||
|
]
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ icon: FileSpreadsheet, title: 'dataHub.stepFiles', hint: 'dataHub.stepFilesHint', to: '/data/files', wrap: 'bg-emerald-50 text-emerald-700' },
|
{ icon: FileSpreadsheet, title: 'dataHub.stepFiles', hint: 'dataHub.stepFilesHint', to: '/data/files', wrap: 'bg-emerald-50 text-emerald-700' },
|
||||||
@@ -98,17 +119,49 @@ export function DataDashboardPage() {
|
|||||||
{!loading && !empty ? (
|
{!loading && !empty ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<StatCard label={t('dataHub.statFiles')} value={files.length} to="/data/files" tone="bg-emerald-500" />
|
<StatCard
|
||||||
<StatCard label={t('dataHub.statDatabases')} value={databases.length} to="/data/databases" tone="bg-sky-600" />
|
label={t('dataHub.statFiles')}
|
||||||
<StatCard label={t('dataHub.statApps')} value={apps.length} to="/data/connectors" tone="bg-violet-500" />
|
value={files.length}
|
||||||
<StatCard label={t('dataHub.statDiscovered')} value={readyMaps} to="/data/map" tone="bg-accent" />
|
to="/data/files"
|
||||||
|
icon={Files}
|
||||||
|
iconWrap="bg-emerald-50 text-emerald-700"
|
||||||
|
wash="bg-emerald-400/25"
|
||||||
|
tone="bg-emerald-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('dataHub.statDatabases')}
|
||||||
|
value={databases.length}
|
||||||
|
to="/data/databases"
|
||||||
|
icon={Database}
|
||||||
|
iconWrap="bg-sky-50 text-sky-800"
|
||||||
|
wash="bg-sky-400/25"
|
||||||
|
tone="bg-sky-600"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('dataHub.statApps')}
|
||||||
|
value={apps.length}
|
||||||
|
to="/data/connectors"
|
||||||
|
icon={Cable}
|
||||||
|
iconWrap="bg-violet-50 text-violet-700"
|
||||||
|
wash="bg-violet-400/25"
|
||||||
|
tone="bg-violet-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('dataHub.statDiscovered')}
|
||||||
|
value={readyMaps}
|
||||||
|
to="/data/map"
|
||||||
|
icon={Network}
|
||||||
|
iconWrap="bg-accent-soft text-accent"
|
||||||
|
wash="bg-accent/20"
|
||||||
|
tone="bg-accent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mb-5 rounded-2xl border border-line bg-panel p-4 shadow-card">
|
<section className="mb-5 overflow-hidden rounded-3xl border border-line bg-panel shadow-card">
|
||||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
|
<div className="grid md:grid-cols-[minmax(0,1fr)_240px]">
|
||||||
<div>
|
<div className="p-5">
|
||||||
<h2 className="text-sm font-semibold text-ink">{t('dataHub.mixLabel')}</h2>
|
<h2 className="text-sm font-semibold text-ink">{t('dataHub.mixLabel')}</h2>
|
||||||
<p className="mt-1 text-[12px] text-ink-muted">
|
<p className="mt-1 max-w-xl text-[13px] leading-6 text-ink-muted">
|
||||||
{mapSources
|
{mapSources
|
||||||
? readyMaps < mapSources
|
? readyMaps < mapSources
|
||||||
? t('dataHub.mapProgress').replace('{ready}', String(readyMaps)).replace('{total}', String(mapSources))
|
? t('dataHub.mapProgress').replace('{ready}', String(readyMaps)).replace('{total}', String(mapSources))
|
||||||
@@ -116,63 +169,99 @@ export function DataDashboardPage() {
|
|||||||
: t('dataHub.stepMapHint')}
|
: t('dataHub.stepMapHint')}
|
||||||
{confirmedEdges ? ` · ${t('dataHub.confirmedLinks').replace('{count}', String(confirmedEdges))}` : ''}
|
{confirmedEdges ? ` · ${t('dataHub.confirmedLinks').replace('{count}', String(confirmedEdges))}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4 flex h-2.5 overflow-hidden rounded-full bg-surface-subtle">
|
||||||
|
{mix.map((part) =>
|
||||||
|
part.value ? (
|
||||||
|
<div
|
||||||
|
key={part.key}
|
||||||
|
className={part.className}
|
||||||
|
style={{ width: `${(part.value / mixTotal) * 100}%` }}
|
||||||
|
title={part.label}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-[12px] text-ink-muted">
|
||||||
|
{mix.map((part) => (
|
||||||
|
<span key={part.key} className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${part.className}`} />
|
||||||
|
{part.label}
|
||||||
|
<span className="tabular-nums text-ink">{part.value}</span>
|
||||||
|
{part.value ? (
|
||||||
|
<span className="tabular-nums text-ink-subtle">
|
||||||
|
{Math.round((part.value / mixTotal) * 100)}٪
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/data/map" className="text-sm font-medium text-accent hover:underline">
|
<Link
|
||||||
{t('dataHub.openOrgMap')}
|
to="/data/map"
|
||||||
</Link>
|
className="flex items-center gap-4 border-t border-line bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.16),transparent_62%)] p-5 transition hover:bg-accent-soft/40 md:border-s md:border-t-0"
|
||||||
</div>
|
>
|
||||||
<div className="flex h-3 overflow-hidden rounded-full bg-surface-subtle">
|
{mapSources ? (
|
||||||
{mix.map((part) =>
|
<CoverageRing ready={readyMaps} total={mapSources} />
|
||||||
part.value ? (
|
) : (
|
||||||
<div
|
<span className="inline-flex h-[88px] w-[88px] shrink-0 items-center justify-center rounded-full bg-white text-accent shadow-card ring-1 ring-line">
|
||||||
key={part.key}
|
<Network className="h-7 w-7" />
|
||||||
className={part.className}
|
</span>
|
||||||
style={{ width: `${(part.value / mixTotal) * 100}%` }}
|
)}
|
||||||
title={part.label}
|
<span className="min-w-0">
|
||||||
/>
|
<span className="block text-sm font-semibold text-ink">{t('dataHub.statDiscovered')}</span>
|
||||||
) : null,
|
<span className="mt-1 block text-[12px] leading-5 text-ink-muted">
|
||||||
)}
|
{mapSources ? `${readyMaps} / ${mapSources}` : t('dataHub.stepMapHint')}
|
||||||
</div>
|
</span>
|
||||||
<div className="mt-3 flex flex-wrap gap-4 text-[12px] text-ink-muted">
|
<span className="mt-2 inline-flex items-center gap-1 text-[12px] font-medium text-accent">
|
||||||
{mix.map((part) => (
|
{t('dataHub.openOrgMap')}
|
||||||
<span key={part.key} className="inline-flex items-center gap-1.5">
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
<span className={`h-2 w-2 rounded-full ${part.className}`} />
|
</span>
|
||||||
{part.label}
|
|
||||||
<span className="tabular-nums text-ink">{part.value}</span>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<RecentList
|
<RecentList
|
||||||
title={t('dataHub.recentFiles')}
|
title={t('dataHub.recentFiles')}
|
||||||
to="/data/files"
|
to="/data/files"
|
||||||
|
icon={Files}
|
||||||
|
iconWrap="bg-emerald-50 text-emerald-700"
|
||||||
|
total={files.length}
|
||||||
empty={files.length === 0}
|
empty={files.length === 0}
|
||||||
rows={files.slice(0, 4).map((file) => ({
|
rows={files.slice(0, 5).map((file) => ({
|
||||||
id: file.id,
|
id: file.id,
|
||||||
title: file.title?.trim() || file.name,
|
title: file.title?.trim() || file.name,
|
||||||
meta: file.original_name || file.name,
|
meta: file.original_name || file.name,
|
||||||
|
mark: <FileMark file={file} />,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<RecentList
|
<RecentList
|
||||||
title={t('dataHub.recentDatabases')}
|
title={t('dataHub.recentDatabases')}
|
||||||
to="/data/databases"
|
to="/data/databases"
|
||||||
|
icon={Database}
|
||||||
|
iconWrap="bg-sky-50 text-sky-800"
|
||||||
|
total={databases.length}
|
||||||
empty={databases.length === 0}
|
empty={databases.length === 0}
|
||||||
rows={databases.slice(0, 4).map((item) => ({
|
rows={databases.slice(0, 5).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
meta: engineById(item.type).label,
|
meta: engineById(item.type).label,
|
||||||
|
mark: <EngineMark id={item.type} className="h-9 w-9" />,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<RecentList
|
<RecentList
|
||||||
title={t('dataHub.recentApps')}
|
title={t('dataHub.recentApps')}
|
||||||
to="/data/connectors"
|
to="/data/connectors"
|
||||||
|
icon={Cable}
|
||||||
|
iconWrap="bg-violet-50 text-violet-700"
|
||||||
|
total={apps.length}
|
||||||
empty={apps.length === 0}
|
empty={apps.length === 0}
|
||||||
rows={apps.slice(0, 4).map((item) => ({
|
rows={apps.slice(0, 5).map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.display_name,
|
title: item.display_name,
|
||||||
meta: item.connector_type,
|
meta: item.connector_type,
|
||||||
|
mark: <AppMark type={item.connector_type} name={item.display_name} />,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,25 +275,106 @@ function StatCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
to,
|
to,
|
||||||
|
icon: Icon,
|
||||||
|
iconWrap,
|
||||||
|
wash,
|
||||||
tone,
|
tone,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
to: string
|
to: string
|
||||||
|
icon: LucideIcon
|
||||||
|
iconWrap: string
|
||||||
|
wash: string
|
||||||
tone: string
|
tone: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className="rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
|
className="group relative overflow-hidden rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_16px_32px_-20px_rgba(23,32,51,0.45)]"
|
||||||
>
|
>
|
||||||
<span className={`mb-3 block h-1.5 w-10 rounded-full ${tone}`} />
|
<span className={`pointer-events-none absolute -top-8 -start-6 h-28 w-28 rounded-full blur-2xl ${wash}`} />
|
||||||
<p className="text-3xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
|
<span className="relative flex items-start justify-between gap-3">
|
||||||
<p className="mt-1 text-sm text-ink-muted">{label}</p>
|
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-xl ${iconWrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<ArrowLeft className="h-4 w-4 text-ink-subtle transition group-hover:-translate-x-0.5 group-hover:text-accent" />
|
||||||
|
</span>
|
||||||
|
<p className="relative mt-4 text-3xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
|
||||||
|
<p className="relative mt-1 text-sm text-ink-muted">{label}</p>
|
||||||
|
<span className={`absolute inset-x-4 bottom-0 h-0.5 origin-right scale-x-0 rounded-full transition group-hover:scale-x-100 ${tone}`} />
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoverageRing({ ready, total }: { ready: number; total: number }) {
|
||||||
|
const size = 88
|
||||||
|
const stroke = 7
|
||||||
|
const radius = (size - stroke) / 2
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const ratio = total > 0 ? Math.min(1, ready / total) : 0
|
||||||
|
const percent = Math.round(ratio * 100)
|
||||||
|
return (
|
||||||
|
<div className="relative h-[88px] w-[88px] shrink-0" aria-hidden>
|
||||||
|
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="text-accent/15"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="text-accent"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${circumference * ratio} ${circumference}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-lg font-semibold leading-none tabular-nums text-ink">{percent}</span>
|
||||||
|
<span className="mt-0.5 text-[10px] font-medium text-ink-muted">٪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileMark({ file }: { file: KamidsoFile }) {
|
||||||
|
const raw = (file.kind || kindFromFilename(file.original_name || file.name || '')).toLowerCase()
|
||||||
|
const kind = raw === 'table' || raw === 'spreadsheet' ? 'excel' : raw
|
||||||
|
const meta =
|
||||||
|
kind === 'csv'
|
||||||
|
? { icon: Table2, wrap: 'bg-sky-50 text-sky-700' }
|
||||||
|
: kind === 'json'
|
||||||
|
? { icon: Braces, wrap: 'bg-amber-50 text-amber-800' }
|
||||||
|
: kind === 'parquet'
|
||||||
|
? { icon: Layers, wrap: 'bg-slate-100 text-slate-700' }
|
||||||
|
: { icon: FileSpreadsheet, wrap: 'bg-emerald-50 text-emerald-700' }
|
||||||
|
const Icon = meta.icon
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${meta.wrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppMark({ type, name }: { type: string; name: string }) {
|
||||||
|
if (type === 'didar') return <DidarMark className="h-9 w-9" />
|
||||||
|
const letter = name.trim().slice(0, 1) || 'ا'
|
||||||
|
return (
|
||||||
|
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-violet-50 text-sm font-bold text-violet-700">
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyGuide() {
|
function EmptyGuide() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -322,32 +492,49 @@ function RecentList({
|
|||||||
to,
|
to,
|
||||||
rows,
|
rows,
|
||||||
empty,
|
empty,
|
||||||
|
total,
|
||||||
|
icon: Icon,
|
||||||
|
iconWrap,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
to: string
|
to: string
|
||||||
rows: Array<{ id: string; title: string; meta: string }>
|
rows: Array<{ id: string; title: string; meta: string; mark: ReactNode }>
|
||||||
empty: boolean
|
empty: boolean
|
||||||
|
total: number
|
||||||
|
icon: LucideIcon
|
||||||
|
iconWrap: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-line bg-panel p-4 shadow-card">
|
<section className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2 border-b border-line px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Link to={to} className="text-[12px] font-medium text-accent hover:underline">
|
<span className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${iconWrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<h2 className="truncate text-sm font-semibold text-ink">{title}</h2>
|
||||||
|
{total ? <span className="text-[12px] tabular-nums text-ink-subtle">{total}</span> : null}
|
||||||
|
</div>
|
||||||
|
<Link to={to} className="shrink-0 text-[12px] font-medium text-accent hover:underline">
|
||||||
{empty ? t('dataHub.add') : t('dataHub.view')}
|
{empty ? t('dataHub.add') : t('dataHub.view')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<Link to={to} className="block rounded-xl border border-dashed border-line px-3 py-6 text-center text-sm text-ink-muted">
|
<Link to={to} className="m-3 block rounded-xl border border-dashed border-line px-3 py-6 text-center text-sm text-ink-muted">
|
||||||
{t('dataHub.noneYet')}
|
{t('dataHub.noneYet')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="p-2">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<li key={row.id} className="rounded-xl bg-surface-subtle px-3 py-2">
|
<li key={row.id}>
|
||||||
<p className="truncate text-sm font-medium text-ink">{row.title}</p>
|
<Link to={to} className="flex items-center gap-3 rounded-xl px-2 py-2 transition hover:bg-surface-subtle">
|
||||||
<p className="truncate text-[11px] text-ink-muted" dir="ltr">
|
{row.mark}
|
||||||
{row.meta}
|
<span className="min-w-0 flex-1">
|
||||||
</p>
|
<span className="block truncate text-sm font-medium text-ink">{row.title}</span>
|
||||||
|
<span className="block truncate text-[11px] text-ink-muted" dir="ltr">
|
||||||
|
{row.meta}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ function configToValues(engineId: EngineId, config?: Record<string, unknown>): R
|
|||||||
if (config?.database && !next.database) next.database = String(config.database)
|
if (config?.database && !next.database) next.database = String(config.database)
|
||||||
if (config?.host && !next.host) next.host = String(config.host)
|
if (config?.host && !next.host) next.host = String(config.host)
|
||||||
if (config?.path && !next.path) next.path = String(config.path)
|
if (config?.path && !next.path) next.path = String(config.path)
|
||||||
|
if (config?.endpoint && !next.endpoint) next.endpoint = String(config.endpoint)
|
||||||
|
if (config?.project && !next.project) next.project = String(config.project)
|
||||||
|
if (config?.access_id && !next.access_id) next.access_id = String(config.access_id)
|
||||||
next.password = ''
|
next.password = ''
|
||||||
|
next.access_key = ''
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export function ConnectEngineModal({
|
|||||||
function payload() {
|
function payload() {
|
||||||
const config: Record<string, unknown> = {}
|
const config: Record<string, unknown> = {}
|
||||||
for (const field of visibleFields) {
|
for (const field of visibleFields) {
|
||||||
if (field === 'password') continue
|
if (field === 'password' || field === 'access_key') continue
|
||||||
const value = (values[field] || '').trim()
|
const value = (values[field] || '').trim()
|
||||||
if (field === 'port') config.port = value ? Number(value) : engine.defaultPort
|
if (field === 'port') config.port = value ? Number(value) : engine.defaultPort
|
||||||
else if (value) config[field] = value
|
else if (value) config[field] = value
|
||||||
@@ -97,11 +101,28 @@ export function ConnectEngineModal({
|
|||||||
name: name.trim() || engine.label,
|
name: name.trim() || engine.label,
|
||||||
type: engine.id,
|
type: engine.id,
|
||||||
config,
|
config,
|
||||||
password: values.password || undefined,
|
password: values.password || values.access_key || undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function missingRequired(): EngineField | undefined {
|
||||||
|
for (const field of visibleFields) {
|
||||||
|
if ((field === 'password' || field === 'access_key') && existing) continue
|
||||||
|
const needed =
|
||||||
|
engine.required.includes(field) ||
|
||||||
|
(engine.id === 'oracle' && (field === 'service_name' || field === 'sid'))
|
||||||
|
if (!needed) continue
|
||||||
|
if (!(values[field] || '').trim()) return field
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
|
const missing = missingRequired()
|
||||||
|
if (missing) {
|
||||||
|
setError(`${t(FIELD_LABEL[missing])} — ${t('databases.testFailed')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
setTesting(true)
|
setTesting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setOkMessage(null)
|
setOkMessage(null)
|
||||||
@@ -117,6 +138,11 @@ export function ConnectEngineModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
const missing = missingRequired()
|
||||||
|
if (missing) {
|
||||||
|
setError(`${t(FIELD_LABEL[missing])} — ${t('databases.saveFailed')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -199,14 +225,20 @@ export function ConnectEngineModal({
|
|||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{visibleFields.map((field) => {
|
{visibleFields.map((field) => {
|
||||||
const isPassword = field === 'password'
|
const isPassword = field === 'password' || field === 'access_key'
|
||||||
const wide = field === 'path' || field === 'host' || field === 'service_name' || field === 'sid'
|
const wide =
|
||||||
|
field === 'path' ||
|
||||||
|
field === 'host' ||
|
||||||
|
field === 'service_name' ||
|
||||||
|
field === 'sid' ||
|
||||||
|
field === 'endpoint'
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={field}
|
key={field}
|
||||||
label={t(FIELD_LABEL[field])}
|
label={t(FIELD_LABEL[field])}
|
||||||
required={
|
required={
|
||||||
engine.required.includes(field) || field === 'service_name' || field === 'sid'
|
(engine.required.includes(field) || field === 'service_name' || field === 'sid') &&
|
||||||
|
!(isPassword && Boolean(existing))
|
||||||
}
|
}
|
||||||
hint={
|
hint={
|
||||||
isPassword && existing
|
isPassword && existing
|
||||||
@@ -220,7 +252,20 @@ export function ConnectEngineModal({
|
|||||||
value={values[field] || ''}
|
value={values[field] || ''}
|
||||||
onChange={(event) => setField(field, event.target.value)}
|
onChange={(event) => setField(field, event.target.value)}
|
||||||
autoComplete={isPassword ? 'new-password' : 'off'}
|
autoComplete={isPassword ? 'new-password' : 'off'}
|
||||||
dir={['path', 'host', 'service_name', 'sid', 'auth_source'].includes(field) ? 'ltr' : undefined}
|
dir={
|
||||||
|
[
|
||||||
|
'path',
|
||||||
|
'host',
|
||||||
|
'service_name',
|
||||||
|
'sid',
|
||||||
|
'auth_source',
|
||||||
|
'endpoint',
|
||||||
|
'project',
|
||||||
|
'access_id',
|
||||||
|
].includes(field)
|
||||||
|
? 'ltr'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
placeholder={isPassword && existing ? '••••••••' : undefined}
|
placeholder={isPassword && existing ? '••••••••' : undefined}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { EngineMark } from './EngineMark'
|
||||||
|
import { ENGINES } from './engines'
|
||||||
|
|
||||||
|
describe('EngineMark', () => {
|
||||||
|
it('gives every engine its own mark, including case-insensitive openGauss', () => {
|
||||||
|
const marks = ENGINES.map((engine) => {
|
||||||
|
const { container } = render(<EngineMark id={engine.id} />)
|
||||||
|
return container.firstElementChild?.getAttribute('data-engine')
|
||||||
|
})
|
||||||
|
expect(marks).toEqual(ENGINES.map((engine) => engine.id))
|
||||||
|
expect(new Set(marks).size).toBe(ENGINES.length)
|
||||||
|
|
||||||
|
const { container } = render(<EngineMark id="opengauss" />)
|
||||||
|
expect(container.firstElementChild).toHaveAttribute('data-engine', 'openGauss')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,27 @@
|
|||||||
import type { EngineId } from './engines'
|
import type { ReactNode } from 'react'
|
||||||
|
import { ENGINES, type EngineId } from './engines'
|
||||||
|
|
||||||
|
const LOGOS: Record<EngineId, () => ReactNode> = {
|
||||||
|
mysql: MysqlLogo,
|
||||||
|
mariadb: MariaLogo,
|
||||||
|
oceanbase: OceanBaseLogo,
|
||||||
|
postgresql: PostgresLogo,
|
||||||
|
oracle: OracleLogo,
|
||||||
|
mssql: MssqlLogo,
|
||||||
|
sqlite: SqliteLogo,
|
||||||
|
duckdb: DuckLogo,
|
||||||
|
clickhouse: ClickHouseLogo,
|
||||||
|
starrocks: StarRocksLogo,
|
||||||
|
doris: DorisLogo,
|
||||||
|
hive: HiveLogo,
|
||||||
|
gaussdb: GaussLogo,
|
||||||
|
openGauss: OpenGaussLogo,
|
||||||
|
vertica: VerticaLogo,
|
||||||
|
maxcompute: MaxComputeLogo,
|
||||||
|
spark: SparkLogo,
|
||||||
|
neo4j: Neo4jLogo,
|
||||||
|
mongodb: MongoLogo,
|
||||||
|
}
|
||||||
|
|
||||||
export function EngineMark({
|
export function EngineMark({
|
||||||
id,
|
id,
|
||||||
@@ -7,121 +30,262 @@ export function EngineMark({
|
|||||||
id: string
|
id: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const engine = (id || 'mysql') as EngineId
|
const resolved = resolveEngineId(id)
|
||||||
|
const Logo = resolved ? LOGOS[resolved] : GenericLogo
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex shrink-0 items-center justify-center ${className}`} aria-hidden>
|
<span
|
||||||
{engine === 'mysql' ? <MysqlLogo /> : null}
|
className={`inline-flex shrink-0 items-center justify-center ${className}`}
|
||||||
{engine === 'postgresql' ? <PostgresLogo /> : null}
|
data-engine={resolved || 'unknown'}
|
||||||
{engine === 'oracle' ? <OracleLogo /> : null}
|
aria-hidden
|
||||||
{engine === 'mssql' ? <MssqlLogo /> : null}
|
>
|
||||||
{engine === 'sqlite' ? <SqliteLogo /> : null}
|
<Logo />
|
||||||
{engine === 'neo4j' ? <Neo4jLogo /> : null}
|
|
||||||
{engine === 'mongodb' ? <MongoLogo /> : null}
|
|
||||||
{!['mysql', 'postgresql', 'oracle', 'mssql', 'sqlite', 'neo4j', 'mongodb'].includes(engine) ? (
|
|
||||||
<GenericLogo />
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEngineId(id?: string | null): EngineId | null {
|
||||||
|
const raw = (id || '').trim()
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
return ENGINES.find((item) => item.id === raw || item.id.toLowerCase() === lower)?.id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame({ fill, children }: { fill: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" className="h-full w-full">
|
||||||
|
<rect width="48" height="48" rx="14" fill={fill} />
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MysqlLogo() {
|
function MysqlLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#E8F7FA">
|
||||||
<rect width="48" height="48" rx="14" fill="#E8F7FA" />
|
|
||||||
<path
|
<path
|
||||||
d="M12 31c3.4-1.2 6.2-6.8 8.8-14.6.6-1.8 2.4-3 4.3-2.7 3.3.5 5.4 3.2 7.2 7.4 1.5 3.6 3.6 6.3 6.7 6.3"
|
d="M11 32c4-2 7-8 10-16 1-2.4 3.2-3.6 5.4-3.2 3.6.6 6 3.6 8 8.2 1.6 3.8 4 6.6 7.6 6.4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#00758F"
|
stroke="#00758F"
|
||||||
strokeWidth="2.6"
|
strokeWidth="2.6"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<path d="M15 18.5c2.4 2.8 3.4 6.8 2.2 10.2" fill="none" stroke="#F29111" strokeWidth="2.2" strokeLinecap="round" />
|
<path d="M14 17c3 4 3.8 9.2 1.6 13" fill="none" stroke="#F29111" strokeWidth="2.2" strokeLinecap="round" />
|
||||||
<circle cx="33.5" cy="27.5" r="1.6" fill="#F29111" />
|
<circle cx="34.2" cy="27.2" r="1.7" fill="#F29111" />
|
||||||
</svg>
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MariaLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#F8F1E6">
|
||||||
|
<ellipse cx="24" cy="30" rx="11" ry="7.5" fill="#C49A6C" />
|
||||||
|
<circle cx="24" cy="20" r="7.2" fill="#A67C52" />
|
||||||
|
<ellipse cx="30.5" cy="22.5" rx="4.2" ry="2.4" fill="#C49A6C" />
|
||||||
|
<circle cx="21.4" cy="18.6" r="1.3" fill="#2C2118" />
|
||||||
|
<path d="M16 22.5c-2 .4-3.6 1.6-4 3M16 25c-2.2.8-3.4 2-3.6 3.4" fill="none" stroke="#8A6944" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OceanBaseLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FDF0EA">
|
||||||
|
<path d="M24 11 36 18v14L24 39 12 32V18Z" fill="#E65A2B" />
|
||||||
|
<path d="M24 18 31 22v8l-7 4-7-4v-8Z" fill="#FDF0EA" />
|
||||||
|
<circle cx="24" cy="24" r="3.2" fill="#E65A2B" />
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostgresLogo() {
|
function PostgresLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#EEF4FA">
|
||||||
<rect width="48" height="48" rx="14" fill="#EEF4FA" />
|
<ellipse cx="24" cy="28" rx="11" ry="9.5" fill="#336791" />
|
||||||
<ellipse cx="24" cy="27" rx="11" ry="10" fill="#336791" />
|
<path d="M15 22c1-8 16-8 18 0 1 4-2 7-9 7s-10-3-9-7Z" fill="#3E7AAB" />
|
||||||
<ellipse cx="24" cy="18.5" rx="8.5" ry="7.5" fill="#3E7AAB" />
|
<path d="M24 13c2.4 0 4.6 1.4 5.2 3.6.4 1.4-.2 2.4-1.6 2.4H20.4c-1.4 0-2-1-1.6-2.4C19.4 14.4 21.6 13 24 13Z" fill="#336791" />
|
||||||
<circle cx="20.5" cy="18" r="1.6" fill="#F7FBFF" />
|
<circle cx="20.6" cy="21.2" r="1.5" fill="#F7FBFF" />
|
||||||
<circle cx="27.5" cy="18" r="1.6" fill="#F7FBFF" />
|
<circle cx="27.4" cy="21.2" r="1.5" fill="#F7FBFF" />
|
||||||
<path d="M19 32.5c1.6 2.4 8.4 2.4 10 0" fill="none" stroke="#EEF4FA" strokeWidth="1.6" strokeLinecap="round" />
|
<path d="M33 24c2.2 2 3 5.2 1.6 8.4" fill="none" stroke="#336791" strokeWidth="2.2" strokeLinecap="round" />
|
||||||
<path d="M33 22c1.8 1.2 2.8 3.4 2.2 6.2" fill="none" stroke="#336791" strokeWidth="2" strokeLinecap="round" />
|
</Frame>
|
||||||
</svg>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OracleLogo() {
|
function OracleLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#FDF1EF">
|
||||||
<rect width="48" height="48" rx="14" fill="#FDF1EF" />
|
|
||||||
<ellipse cx="24" cy="24" rx="14" ry="8.5" fill="none" stroke="#C74634" strokeWidth="3.2" />
|
<ellipse cx="24" cy="24" rx="14" ry="8.5" fill="none" stroke="#C74634" strokeWidth="3.2" />
|
||||||
<ellipse cx="24" cy="24" rx="7.5" ry="4.4" fill="#C74634" />
|
<ellipse cx="24" cy="24" rx="7.5" ry="4.4" fill="#C74634" />
|
||||||
</svg>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MssqlLogo() {
|
function MssqlLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#FBF0F1">
|
||||||
<rect width="48" height="48" rx="14" fill="#FBF0F1" />
|
<ellipse cx="24" cy="15" rx="11" ry="4" fill="#A12127" />
|
||||||
<rect x="12" y="12" width="10.5" height="10.5" rx="2" fill="#F25022" />
|
<path d="M13 15v7c0 2.2 4.9 4 11 4s11-1.8 11-4v-7" fill="#C32F36" />
|
||||||
<rect x="25.5" y="12" width="10.5" height="10.5" rx="2" fill="#7FBA00" />
|
<ellipse cx="24" cy="22" rx="11" ry="4" fill="#A12127" />
|
||||||
<rect x="12" y="25.5" width="10.5" height="10.5" rx="2" fill="#00A4EF" />
|
<path d="M13 22v8c0 2.2 4.9 4 11 4s11-1.8 11-4v-8" fill="#7A1A1F" />
|
||||||
<rect x="25.5" y="25.5" width="10.5" height="10.5" rx="2" fill="#FFB900" />
|
<ellipse cx="24" cy="30" rx="11" ry="4" fill="#A12127" />
|
||||||
</svg>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SqliteLogo() {
|
function SqliteLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#EEF7FB">
|
||||||
<rect width="48" height="48" rx="14" fill="#EEF7FB" />
|
|
||||||
<path
|
<path
|
||||||
d="M16 14h12c6 0 10 3.6 10 9s-4 9-10 9H22l-6 6V14z"
|
d="M18 12c6 3 10 9 12 16 1.4 5 .6 10-2 13-1.2-3.2-1.4-7.2-.4-11.2C30 22 27 16.5 21.5 13.5 19.4 12.4 17.4 12 16 12c.6 0 1.4 0 2 0Z"
|
||||||
fill="#0B4F6C"
|
fill="#0B4F6C"
|
||||||
/>
|
/>
|
||||||
<path d="M20 20h10M20 25h7" stroke="#EEF7FB" strokeWidth="2" strokeLinecap="round" />
|
<path d="M20 16c4 2.4 7 7 8.4 12" fill="none" stroke="#7EB7D0" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
</svg>
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DuckLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FFFBEA">
|
||||||
|
<ellipse cx="22" cy="28" rx="11" ry="9" fill="#F5D000" />
|
||||||
|
<circle cx="30" cy="20" r="7" fill="#FFE44D" />
|
||||||
|
<path d="M36 20c3.2.2 5.6 1.6 5.6 3.2 0 1.4-2.2 2.2-5.2 2.2" fill="#F29111" />
|
||||||
|
<circle cx="32.2" cy="18.4" r="1.4" fill="#1F2937" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClickHouseLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FFF8E8">
|
||||||
|
<path d="M10 26 24 12l14 14v14H10V26Z" fill="#F5C518" />
|
||||||
|
<path d="M20 40V30h8v10" fill="#FFF8E8" />
|
||||||
|
<rect x="30" y="14" width="4.5" height="8" rx="1" fill="#D4A017" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarRocksLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#EEF1FC">
|
||||||
|
<path
|
||||||
|
d="M24 10l3.4 9.2H37l-7.6 5.6 2.8 9.2L24 29.2 15.8 34l2.8-9.2L11 19.2h9.6Z"
|
||||||
|
fill="#3759D4"
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DorisLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#EEF1FA">
|
||||||
|
<path
|
||||||
|
d="M24 36c-6.4 0-10-4.6-10-11 0-6 4.2-10.4 10-10.4S34 19 34 25c0 6.4-3.6 11-10 11Z"
|
||||||
|
fill="#4458C7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 16c-2-4-1-8 1.4-9 1.6 2.4 3.6 4.4 6.2 5.6M32 16c2-4 1-8-1.4-9-1.6 2.4-3.6 4.4-6.2 5.6"
|
||||||
|
fill="none"
|
||||||
|
stroke="#4458C7"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="21" cy="24" r="1.5" fill="#EEF1FA" />
|
||||||
|
<circle cx="27" cy="24" r="1.5" fill="#EEF1FA" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HiveLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FFFBEB">
|
||||||
|
<path d="M24 10 32 15v10l-8 5-8-5V15Z" fill="#F5C518" />
|
||||||
|
<path d="M16 25 24 30l8-5v8L24 38l-8-5v-8Z" fill="#E0A800" />
|
||||||
|
<path d="M24 20 29 23v6l-5 3-5-3v-6Z" fill="#FFF6C7" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GaussLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FDF0F1">
|
||||||
|
<circle cx="24" cy="24" r="13" fill="#C7000B" />
|
||||||
|
<path d="M30 24a6 6 0 1 1-1.6-4.2" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<path d="M28.4 24H24" stroke="#fff" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenGaussLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FDF0F1">
|
||||||
|
<path d="M24 9 35 16v16L24 39 13 32V16Z" fill="none" stroke="#E60012" strokeWidth="2.8" />
|
||||||
|
<path d="M24 16 31 20v8l-7 4-7-4v-8Z" fill="#E60012" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerticaLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#EAF2F9">
|
||||||
|
<path d="M24 12 36 36H12L24 12Z" fill="#1A5A96" />
|
||||||
|
<path d="M24 20 30.5 34h-13L24 20Z" fill="#EAF2F9" />
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaxComputeLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FFF3EB">
|
||||||
|
<path
|
||||||
|
d="M16 30c-4 0-6.5-2.6-6.5-6.2 0-3.2 2.2-5.6 5.2-6.2C16 13.6 20 11 24.6 11c4.8 0 8.4 3 9.2 7.2 3.6.4 6.2 3.2 6.2 6.8 0 3.8-3 6.8-7.2 6.8H16Z"
|
||||||
|
fill="#FF6A00"
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparkLogo() {
|
||||||
|
return (
|
||||||
|
<Frame fill="#FDF0EA">
|
||||||
|
<path
|
||||||
|
d="M24 8v32M10 24h28M14 14l20 20M34 14 14 34"
|
||||||
|
stroke="#E25A1C"
|
||||||
|
strokeWidth="3.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="24" cy="24" r="4.2" fill="#E25A1C" />
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Neo4jLogo() {
|
function Neo4jLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#EAF8F6">
|
||||||
<rect width="48" height="48" rx="14" fill="#EAF8F6" />
|
|
||||||
<circle cx="16" cy="24" r="4.2" fill="#018B8B" />
|
<circle cx="16" cy="24" r="4.2" fill="#018B8B" />
|
||||||
<circle cx="32" cy="16.5" r="3.4" fill="#0FA3B1" />
|
<circle cx="32" cy="16.5" r="3.4" fill="#0FA3B1" />
|
||||||
<circle cx="33" cy="32" r="3.8" fill="#14746F" />
|
<circle cx="33" cy="32" r="3.8" fill="#14746F" />
|
||||||
<path d="M19.6 22.2 29.2 17.8M19.8 26.2 30 30.6M32.2 20.2 32.6 28.2" stroke="#018B8B" strokeWidth="2" />
|
<path d="M19.6 22.2 29.2 17.8M19.8 26.2 30 30.6M32.2 20.2 32.6 28.2" stroke="#018B8B" strokeWidth="2" />
|
||||||
</svg>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MongoLogo() {
|
function MongoLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#E9F8F1">
|
||||||
<rect width="48" height="48" rx="14" fill="#E9F8F1" />
|
|
||||||
<path
|
<path
|
||||||
d="M24 10c1.4 4 8 8.8 8 18 0 6.2-3.2 10.4-8 12.5C19.2 38.4 16 34.2 16 28c0-9.2 6.6-14 8-18z"
|
d="M24 10c1.4 4 8 8.8 8 18 0 6.2-3.2 10.4-8 12.5C19.2 38.4 16 34.2 16 28c0-9.2 6.6-14 8-18z"
|
||||||
fill="#00684A"
|
fill="#00684A"
|
||||||
/>
|
/>
|
||||||
<path d="M24 12c.4 8 .5 14.5 0 27" stroke="#13AA52" strokeWidth="2" strokeLinecap="round" />
|
<path d="M24 12c.4 8 .5 14.5 0 27" stroke="#13AA52" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GenericLogo() {
|
function GenericLogo() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 48 48" className="h-full w-full">
|
<Frame fill="#EFF6FF">
|
||||||
<rect width="48" height="48" rx="14" fill="#EFF6FF" />
|
|
||||||
<ellipse cx="24" cy="16" rx="11" ry="4" fill="#2563EB" />
|
<ellipse cx="24" cy="16" rx="11" ry="4" fill="#2563EB" />
|
||||||
<path d="M13 16v12c0 2.2 4.9 4 11 4s11-1.8 11-4V16" fill="none" stroke="#2563EB" strokeWidth="2.4" />
|
<path d="M13 16v12c0 2.2 4.9 4 11 4s11-1.8 11-4V16" fill="none" stroke="#2563EB" strokeWidth="2.4" />
|
||||||
<path d="M13 22c0 2.2 4.9 4 11 4s11-1.8 11-4" fill="none" stroke="#2563EB" strokeWidth="2.4" />
|
<path d="M13 22c0 2.2 4.9 4 11 4s11-1.8 11-4" fill="none" stroke="#2563EB" strokeWidth="2.4" />
|
||||||
</svg>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { defaultValues, ENGINES, engineById, FILE_ENGINE_IDS } from './engines'
|
||||||
|
|
||||||
|
describe('ENGINES catalog', () => {
|
||||||
|
it('offers every sql_query engine the user can connect', () => {
|
||||||
|
expect(ENGINES.map((item) => item.id)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'mysql',
|
||||||
|
'mariadb',
|
||||||
|
'oceanbase',
|
||||||
|
'postgresql',
|
||||||
|
'mssql',
|
||||||
|
'oracle',
|
||||||
|
'sqlite',
|
||||||
|
'duckdb',
|
||||||
|
'clickhouse',
|
||||||
|
'starrocks',
|
||||||
|
'doris',
|
||||||
|
'hive',
|
||||||
|
'gaussdb',
|
||||||
|
'openGauss',
|
||||||
|
'vertica',
|
||||||
|
'maxcompute',
|
||||||
|
'spark',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives MaxCompute cloud fields instead of host and port', () => {
|
||||||
|
const engine = engineById('maxcompute')
|
||||||
|
expect(engine.fields).toEqual(['endpoint', 'project', 'access_id', 'access_key'])
|
||||||
|
expect(defaultValues(engine).endpoint).toContain('maxcompute')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats DuckDB and Spark as local files', () => {
|
||||||
|
expect(FILE_ENGINE_IDS.has('duckdb')).toBe(true)
|
||||||
|
expect(FILE_ENGINE_IDS.has('spark')).toBe(true)
|
||||||
|
expect(engineById('duckdb').fields).toEqual(['path'])
|
||||||
|
expect(engineById('opengauss').id).toBe('openGauss')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,23 @@
|
|||||||
export type EngineId = 'mysql' | 'postgresql' | 'oracle' | 'mssql' | 'sqlite' | 'neo4j' | 'mongodb'
|
export type EngineId =
|
||||||
|
| 'mysql'
|
||||||
|
| 'mariadb'
|
||||||
|
| 'oceanbase'
|
||||||
|
| 'postgresql'
|
||||||
|
| 'mssql'
|
||||||
|
| 'oracle'
|
||||||
|
| 'sqlite'
|
||||||
|
| 'duckdb'
|
||||||
|
| 'clickhouse'
|
||||||
|
| 'starrocks'
|
||||||
|
| 'doris'
|
||||||
|
| 'hive'
|
||||||
|
| 'gaussdb'
|
||||||
|
| 'openGauss'
|
||||||
|
| 'vertica'
|
||||||
|
| 'maxcompute'
|
||||||
|
| 'spark'
|
||||||
|
| 'neo4j'
|
||||||
|
| 'mongodb'
|
||||||
|
|
||||||
export type EngineField =
|
export type EngineField =
|
||||||
| 'host'
|
| 'host'
|
||||||
@@ -11,6 +30,10 @@ export type EngineField =
|
|||||||
| 'service_name'
|
| 'service_name'
|
||||||
| 'sid'
|
| 'sid'
|
||||||
| 'auth_source'
|
| 'auth_source'
|
||||||
|
| 'endpoint'
|
||||||
|
| 'project'
|
||||||
|
| 'access_id'
|
||||||
|
| 'access_key'
|
||||||
|
|
||||||
export type EngineDef = {
|
export type EngineDef = {
|
||||||
id: EngineId
|
id: EngineId
|
||||||
@@ -25,55 +48,39 @@ export type EngineDef = {
|
|||||||
kindKey: string
|
kindKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostSql = (
|
||||||
|
id: EngineId,
|
||||||
|
label: string,
|
||||||
|
port: number,
|
||||||
|
accent: string,
|
||||||
|
accentSoft: string,
|
||||||
|
blurbKey: string,
|
||||||
|
extra: Partial<EngineDef> = {},
|
||||||
|
): EngineDef => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
kind: extra.kind || 'sql',
|
||||||
|
defaultPort: extra.defaultPort ?? port,
|
||||||
|
fields: extra.fields || ['host', 'port', 'user', 'password', 'database'],
|
||||||
|
required: extra.required || ['host', 'user', 'database'],
|
||||||
|
accent: extra.accent || accent,
|
||||||
|
accentSoft: extra.accentSoft || accentSoft,
|
||||||
|
blurbKey: extra.blurbKey || blurbKey,
|
||||||
|
kindKey: extra.kindKey || 'databases.kindSql',
|
||||||
|
})
|
||||||
|
|
||||||
export const ENGINES: EngineDef[] = [
|
export const ENGINES: EngineDef[] = [
|
||||||
{
|
hostSql('mysql', 'MySQL', 3306, '#00758F', '#E8F7FA', 'databases.blurbMysql'),
|
||||||
id: 'mysql',
|
hostSql('mariadb', 'MariaDB', 3306, '#C49A6C', '#F8F1E6', 'databases.blurbMariadb'),
|
||||||
label: 'MySQL',
|
hostSql('oceanbase', 'OceanBase', 2881, '#E65A2B', '#FDF0EA', 'databases.blurbOceanbase'),
|
||||||
kind: 'sql',
|
hostSql('postgresql', 'PostgreSQL', 5432, '#336791', '#EEF4FA', 'databases.blurbPostgres', {
|
||||||
defaultPort: 3306,
|
|
||||||
fields: ['host', 'port', 'user', 'password', 'database'],
|
|
||||||
required: ['host', 'user', 'database'],
|
|
||||||
accent: '#00758F',
|
|
||||||
accentSoft: '#E8F7FA',
|
|
||||||
blurbKey: 'databases.blurbMysql',
|
|
||||||
kindKey: 'databases.kindSql',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'postgresql',
|
|
||||||
label: 'PostgreSQL',
|
|
||||||
kind: 'sql',
|
|
||||||
defaultPort: 5432,
|
|
||||||
fields: ['host', 'port', 'user', 'password', 'database', 'schema'],
|
fields: ['host', 'port', 'user', 'password', 'database', 'schema'],
|
||||||
required: ['host', 'user', 'database'],
|
}),
|
||||||
accent: '#336791',
|
hostSql('oracle', 'Oracle', 1521, '#C74634', '#FDF1EF', 'databases.blurbOracle', {
|
||||||
accentSoft: '#EEF4FA',
|
|
||||||
blurbKey: 'databases.blurbPostgres',
|
|
||||||
kindKey: 'databases.kindSql',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'oracle',
|
|
||||||
label: 'Oracle',
|
|
||||||
kind: 'sql',
|
|
||||||
defaultPort: 1521,
|
|
||||||
fields: ['host', 'port', 'user', 'password', 'service_name', 'sid'],
|
fields: ['host', 'port', 'user', 'password', 'service_name', 'sid'],
|
||||||
required: ['host', 'user'],
|
required: ['host', 'user'],
|
||||||
accent: '#C74634',
|
}),
|
||||||
accentSoft: '#FDF1EF',
|
hostSql('mssql', 'SQL Server', 1433, '#A12127', '#FBF0F1', 'databases.blurbMssql'),
|
||||||
blurbKey: 'databases.blurbOracle',
|
|
||||||
kindKey: 'databases.kindSql',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mssql',
|
|
||||||
label: 'SQL Server',
|
|
||||||
kind: 'sql',
|
|
||||||
defaultPort: 1433,
|
|
||||||
fields: ['host', 'port', 'user', 'password', 'database'],
|
|
||||||
required: ['host', 'user', 'database'],
|
|
||||||
accent: '#A12127',
|
|
||||||
accentSoft: '#FBF0F1',
|
|
||||||
blurbKey: 'databases.blurbMssql',
|
|
||||||
kindKey: 'databases.kindSql',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'sqlite',
|
id: 'sqlite',
|
||||||
label: 'SQLite',
|
label: 'SQLite',
|
||||||
@@ -86,6 +93,55 @@ export const ENGINES: EngineDef[] = [
|
|||||||
blurbKey: 'databases.blurbSqlite',
|
blurbKey: 'databases.blurbSqlite',
|
||||||
kindKey: 'databases.kindFile',
|
kindKey: 'databases.kindFile',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'duckdb',
|
||||||
|
label: 'DuckDB',
|
||||||
|
kind: 'file',
|
||||||
|
defaultPort: null,
|
||||||
|
fields: ['path'],
|
||||||
|
required: ['path'],
|
||||||
|
accent: '#FFF000',
|
||||||
|
accentSoft: '#FFFBEA',
|
||||||
|
blurbKey: 'databases.blurbDuckdb',
|
||||||
|
kindKey: 'databases.kindFile',
|
||||||
|
},
|
||||||
|
hostSql('clickhouse', 'ClickHouse', 8123, '#FBBF24', '#FFF8E8', 'databases.blurbClickhouse'),
|
||||||
|
hostSql('starrocks', 'StarRocks', 9030, '#3759D4', '#EEF1FC', 'databases.blurbStarrocks'),
|
||||||
|
hostSql('doris', 'Apache Doris', 9030, '#4458C7', '#EEF1FA', 'databases.blurbDoris'),
|
||||||
|
hostSql('hive', 'Apache Hive', 10000, '#FDE047', '#FFFBEB', 'databases.blurbHive', {
|
||||||
|
required: ['host'],
|
||||||
|
}),
|
||||||
|
hostSql('gaussdb', 'GaussDB', 5432, '#C7000B', '#FDF0F1', 'databases.blurbGaussdb', {
|
||||||
|
fields: ['host', 'port', 'user', 'password', 'database', 'schema'],
|
||||||
|
}),
|
||||||
|
hostSql('openGauss', 'openGauss', 5432, '#E60012', '#FDF0F1', 'databases.blurbOpengauss', {
|
||||||
|
fields: ['host', 'port', 'user', 'password', 'database', 'schema'],
|
||||||
|
}),
|
||||||
|
hostSql('vertica', 'Vertica', 5433, '#1A5A96', '#EAF2F9', 'databases.blurbVertica'),
|
||||||
|
{
|
||||||
|
id: 'maxcompute',
|
||||||
|
label: 'MaxCompute',
|
||||||
|
kind: 'sql',
|
||||||
|
defaultPort: null,
|
||||||
|
fields: ['endpoint', 'project', 'access_id', 'access_key'],
|
||||||
|
required: ['endpoint', 'project', 'access_id', 'access_key'],
|
||||||
|
accent: '#FF6A00',
|
||||||
|
accentSoft: '#FFF3EB',
|
||||||
|
blurbKey: 'databases.blurbMaxcompute',
|
||||||
|
kindKey: 'databases.kindSql',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spark',
|
||||||
|
label: 'Apache Spark',
|
||||||
|
kind: 'file',
|
||||||
|
defaultPort: null,
|
||||||
|
fields: ['path'],
|
||||||
|
required: ['path'],
|
||||||
|
accent: '#E25A1C',
|
||||||
|
accentSoft: '#FDF0EA',
|
||||||
|
blurbKey: 'databases.blurbSpark',
|
||||||
|
kindKey: 'databases.kindFile',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'neo4j',
|
id: 'neo4j',
|
||||||
label: 'Neo4j',
|
label: 'Neo4j',
|
||||||
@@ -113,7 +169,13 @@ export const ENGINES: EngineDef[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function engineById(id?: string | null): EngineDef {
|
export function engineById(id?: string | null): EngineDef {
|
||||||
return ENGINES.find((item) => item.id === id) || ENGINES[0]!
|
const raw = (id || '').trim()
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
return (
|
||||||
|
ENGINES.find((item) => item.id === raw) ||
|
||||||
|
ENGINES.find((item) => item.id.toLowerCase() === lower) ||
|
||||||
|
ENGINES[0]!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultValues(engine: EngineDef): Record<string, string> {
|
export function defaultValues(engine: EngineDef): Record<string, string> {
|
||||||
@@ -123,7 +185,9 @@ export function defaultValues(engine: EngineDef): Record<string, string> {
|
|||||||
else if (field === 'schema') next.schema = 'public'
|
else if (field === 'schema') next.schema = 'public'
|
||||||
else if (field === 'path' && engine.id === 'sqlite') next.path = '/app/pilot/examples/Walmart_Sales.db'
|
else if (field === 'path' && engine.id === 'sqlite') next.path = '/app/pilot/examples/Walmart_Sales.db'
|
||||||
else if (field === 'database' && engine.id === 'neo4j') next.database = 'neo4j'
|
else if (field === 'database' && engine.id === 'neo4j') next.database = 'neo4j'
|
||||||
|
else if (field === 'database' && engine.id === 'hive') next.database = 'default'
|
||||||
else if (field === 'auth_source') next.auth_source = 'admin'
|
else if (field === 'auth_source') next.auth_source = 'admin'
|
||||||
|
else if (field === 'endpoint') next.endpoint = 'https://service.cn-hangzhou.maxcompute.aliyun.com/api'
|
||||||
else next[field] = ''
|
else next[field] = ''
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -140,12 +204,18 @@ export const FIELD_LABEL: Record<EngineField, string> = {
|
|||||||
service_name: 'databases.fieldServiceName',
|
service_name: 'databases.fieldServiceName',
|
||||||
sid: 'databases.fieldSid',
|
sid: 'databases.fieldSid',
|
||||||
auth_source: 'databases.fieldAuthSource',
|
auth_source: 'databases.fieldAuthSource',
|
||||||
|
endpoint: 'databases.fieldEndpoint',
|
||||||
|
project: 'databases.fieldProject',
|
||||||
|
access_id: 'databases.fieldAccessId',
|
||||||
|
access_key: 'databases.fieldAccessKey',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FIELD_HINT: Record<string, string> = {
|
export const FIELD_HINT: Record<string, string> = {
|
||||||
'mysql.host': 'databases.hintMysqlHost',
|
'mysql.host': 'databases.hintMysqlHost',
|
||||||
'mysql.port': 'databases.hintMysqlPort',
|
'mysql.port': 'databases.hintMysqlPort',
|
||||||
'mysql.database': 'databases.hintMysqlDatabase',
|
'mysql.database': 'databases.hintMysqlDatabase',
|
||||||
|
'mariadb.port': 'databases.hintMysqlPort',
|
||||||
|
'oceanbase.port': 'databases.hintOceanbasePort',
|
||||||
'postgresql.host': 'databases.hintPgHost',
|
'postgresql.host': 'databases.hintPgHost',
|
||||||
'postgresql.port': 'databases.hintPgPort',
|
'postgresql.port': 'databases.hintPgPort',
|
||||||
'postgresql.schema': 'databases.hintPgSchema',
|
'postgresql.schema': 'databases.hintPgSchema',
|
||||||
@@ -154,8 +224,22 @@ export const FIELD_HINT: Record<string, string> = {
|
|||||||
'oracle.sid': 'databases.hintOracleSid',
|
'oracle.sid': 'databases.hintOracleSid',
|
||||||
'mssql.port': 'databases.hintMssqlPort',
|
'mssql.port': 'databases.hintMssqlPort',
|
||||||
'sqlite.path': 'databases.hintSqlitePath',
|
'sqlite.path': 'databases.hintSqlitePath',
|
||||||
|
'duckdb.path': 'databases.hintDuckdbPath',
|
||||||
|
'clickhouse.port': 'databases.hintClickhousePort',
|
||||||
|
'starrocks.port': 'databases.hintStarrocksPort',
|
||||||
|
'doris.port': 'databases.hintDorisPort',
|
||||||
|
'hive.port': 'databases.hintHivePort',
|
||||||
|
'hive.database': 'databases.hintHiveDatabase',
|
||||||
|
'gaussdb.schema': 'databases.hintPgSchema',
|
||||||
|
'openGauss.schema': 'databases.hintPgSchema',
|
||||||
|
'vertica.port': 'databases.hintVerticaPort',
|
||||||
|
'maxcompute.endpoint': 'databases.hintMaxcomputeEndpoint',
|
||||||
|
'maxcompute.project': 'databases.hintMaxcomputeProject',
|
||||||
|
'spark.path': 'databases.hintSparkPath',
|
||||||
'neo4j.port': 'databases.hintNeo4jPort',
|
'neo4j.port': 'databases.hintNeo4jPort',
|
||||||
'neo4j.database': 'databases.hintNeo4jDatabase',
|
'neo4j.database': 'databases.hintNeo4jDatabase',
|
||||||
'mongodb.port': 'databases.hintMongoPort',
|
'mongodb.port': 'databases.hintMongoPort',
|
||||||
'mongodb.auth_source': 'databases.hintMongoAuth',
|
'mongodb.auth_source': 'databases.hintMongoAuth',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const FILE_ENGINE_IDS = new Set<EngineId>(['sqlite', 'duckdb', 'spark'])
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { Button } from '../../components/Button'
|
||||||
import { Input } from '../../components/ui/Field'
|
import { Input } from '../../components/ui/Field'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
import { listKnowledgeChunks } from './api'
|
import { listKnowledgeChunks } from './api'
|
||||||
import type { KnowledgeChunk } from '../../contracts/knowledge'
|
import type { KnowledgeChunk } from '../../contracts/knowledge'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12
|
||||||
|
|
||||||
export function ChunkBrowser({
|
export function ChunkBrowser({
|
||||||
spaceId,
|
spaceId,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -20,7 +24,7 @@ export function ChunkBrowser({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
void listKnowledgeChunks(spaceId, documentId, { page, pageSize: 12, q: query || undefined })
|
void listKnowledgeChunks(spaceId, documentId, { page, pageSize: PAGE_SIZE, q: query || undefined })
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setItems(payload.items)
|
setItems(payload.items)
|
||||||
@@ -40,45 +44,57 @@ export function ChunkBrowser({
|
|||||||
}
|
}
|
||||||
}, [spaceId, documentId, page, query])
|
}, [spaceId, documentId, page, query])
|
||||||
|
|
||||||
|
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<p className="mb-3 text-[13px] leading-6 text-ink-muted">{t('knowledge.chunksLead')}</p>
|
||||||
value={query}
|
<div className="max-w-md">
|
||||||
onChange={(event) => {
|
<Input
|
||||||
setPage(1)
|
value={query}
|
||||||
setQuery(event.target.value)
|
onChange={(event) => {
|
||||||
}}
|
setPage(1)
|
||||||
placeholder={t('knowledge.searchChunks')}
|
setQuery(event.target.value)
|
||||||
/>
|
}}
|
||||||
|
placeholder={t('knowledge.searchChunks')}
|
||||||
|
leadingIcon={<Search className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="mt-4 text-sm text-ink-muted">{t('knowledge.loading')}</p>
|
<p className="mt-4 text-sm text-ink-muted">{t('knowledge.loading')}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
|
<div className="mt-4 rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
|
||||||
|
{t('knowledge.chunksEmpty')}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{items.map((chunk) => (
|
{items.map((chunk, index) => (
|
||||||
<article key={chunk.id} className="rounded-xl border border-line bg-panel p-4">
|
<article key={chunk.id} className="rounded-2xl border border-line bg-panel p-4 shadow-card">
|
||||||
|
<p className="mb-2 text-[11px] font-medium text-ink-subtle">
|
||||||
|
{t('knowledge.chunks')} {(page - 1) * PAGE_SIZE + index + 1}
|
||||||
|
</p>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{chunk.content}</p>
|
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{chunk.content}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{total > 12 ? (
|
{total > PAGE_SIZE ? (
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-ink-muted">
|
<div className="mt-4 flex items-center justify-between gap-3">
|
||||||
<span>
|
<span className="text-xs text-ink-muted">
|
||||||
{t('knowledge.page')} {page}
|
{t('knowledge.page')} {page} / {pages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
<Button variant="secondary" size="sm" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
||||||
{t('knowledge.prev')}
|
{t('knowledge.prev')}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="secondary"
|
||||||
disabled={page * 12 >= total}
|
size="sm"
|
||||||
|
disabled={page * PAGE_SIZE >= total}
|
||||||
onClick={() => setPage((value) => value + 1)}
|
onClick={() => setPage((value) => value + 1)}
|
||||||
>
|
>
|
||||||
{t('knowledge.next')}
|
{t('knowledge.next')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function CreateSpaceModal({
|
|||||||
title,
|
title,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
variant = 'space',
|
variant = 'space',
|
||||||
|
initialKind,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
@@ -51,6 +52,7 @@ export function CreateSpaceModal({
|
|||||||
title: string
|
title: string
|
||||||
submitLabel: string
|
submitLabel: string
|
||||||
variant?: 'space' | 'document'
|
variant?: 'space' | 'document'
|
||||||
|
initialKind?: SourceKind
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (payload: {
|
onSubmit: (payload: {
|
||||||
name: string
|
name: string
|
||||||
@@ -82,7 +84,7 @@ export function CreateSpaceModal({
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
setName('')
|
setName('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setKind('file')
|
setKind(initialKind || 'file')
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setText('')
|
setText('')
|
||||||
setTextTitle('')
|
setTextTitle('')
|
||||||
@@ -93,7 +95,7 @@ export function CreateSpaceModal({
|
|||||||
setError(null)
|
setError(null)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
void listWorkspaceFiles().then(setLibrary).catch(() => setLibrary([]))
|
void listWorkspaceFiles().then(setLibrary).catch(() => setLibrary([]))
|
||||||
}, [open])
|
}, [open, initialKind])
|
||||||
|
|
||||||
const knowledgeLibrary = useMemo(
|
const knowledgeLibrary = useMemo(
|
||||||
() => library.filter((file) => isKnowledgeFileKind(file.kind)),
|
() => library.filter((file) => isKnowledgeFileKind(file.kind)),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RefreshCw, Trash2 } from 'lucide-react'
|
import { FileText, Globe, RefreshCw, Trash2, Type, type LucideIcon } from 'lucide-react'
|
||||||
|
import { Button } from '../../components/Button'
|
||||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
import type { KnowledgeDocument } from '../../contracts/knowledge'
|
import type { KnowledgeDocument } from '../../contracts/knowledge'
|
||||||
@@ -9,65 +10,84 @@ export function DocumentTable({
|
|||||||
onSync,
|
onSync,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOpenChunks,
|
onOpenChunks,
|
||||||
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
documents: KnowledgeDocument[]
|
documents: KnowledgeDocument[]
|
||||||
onSync: (id: string) => void
|
onSync: (id: string) => void
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
onOpenChunks: (id: string) => void
|
onOpenChunks: (id: string) => void
|
||||||
|
onAdd?: () => void
|
||||||
}) {
|
}) {
|
||||||
if (!documents.length) {
|
if (!documents.length) {
|
||||||
return <p className="py-8 text-center text-sm text-ink-muted">{t('knowledge.documentsEmpty')}</p>
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-line bg-panel px-6 py-14 text-center shadow-card">
|
||||||
|
<span className="mx-auto inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-4 text-base font-semibold text-ink">{t('knowledge.documentsEmptyTitle')}</h3>
|
||||||
|
<p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-ink-muted">{t('knowledge.documentsEmptyBody')}</p>
|
||||||
|
{onAdd ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Button onClick={onAdd}>{t('knowledge.addDocument')}</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-line">
|
<ul className="overflow-hidden rounded-2xl border border-line bg-panel shadow-card">
|
||||||
<table className="w-full text-sm">
|
{documents.map((doc) => {
|
||||||
<thead className="bg-surface-subtle text-xs text-ink-muted">
|
const meta = docMark(doc.doc_type)
|
||||||
<tr>
|
const Icon = meta.icon
|
||||||
<th className="px-4 py-3 text-right font-medium">{t('knowledge.document')}</th>
|
return (
|
||||||
<th className="px-4 py-3 text-right font-medium">{t('knowledge.type')}</th>
|
<li key={doc.id} className="border-b border-line last:border-b-0">
|
||||||
<th className="px-4 py-3 text-right font-medium">{t('knowledge.chunks')}</th>
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<th className="px-4 py-3 text-right font-medium">{t('knowledge.status')}</th>
|
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${meta.wrap}`}>
|
||||||
<th className="px-4 py-3" />
|
<Icon className="h-4 w-4" />
|
||||||
</tr>
|
</span>
|
||||||
</thead>
|
<button type="button" className="min-w-0 flex-1 text-right" onClick={() => onOpenChunks(doc.id)}>
|
||||||
<tbody>
|
<span className="block truncate text-sm font-semibold text-ink hover:text-accent">{doc.name}</span>
|
||||||
{documents.map((doc) => (
|
<span className="mt-0.5 block truncate text-[12px] text-ink-muted">
|
||||||
<tr key={doc.id} className="border-t border-line">
|
{doc.doc_type} · {doc.chunk_size} {t('knowledge.chunks')}
|
||||||
<td className="px-4 py-3">
|
</span>
|
||||||
<button type="button" className="font-medium text-ink hover:text-accent" onClick={() => onOpenChunks(doc.id)}>
|
{doc.result ? <span className="mt-1 block truncate text-xs text-danger">{doc.result}</span> : null}
|
||||||
{doc.name}
|
</button>
|
||||||
|
<StatusBadge status={doc.status} label={documentStatusLabel(doc.status)} />
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-accent-soft hover:text-accent"
|
||||||
|
aria-label={t('knowledge.resync')}
|
||||||
|
onClick={() => onSync(doc.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{doc.result ? <p className="mt-1 text-xs text-danger">{doc.result}</p> : null}
|
<button
|
||||||
</td>
|
type="button"
|
||||||
<td className="px-4 py-3 text-ink-muted">{doc.doc_type}</td>
|
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||||
<td className="px-4 py-3 text-ink-muted">{doc.chunk_size}</td>
|
aria-label={t('knowledge.deleteDocument')}
|
||||||
<td className="px-4 py-3">
|
onClick={() => onDelete(doc.id)}
|
||||||
<StatusBadge status={doc.status} label={documentStatusLabel(doc.status)} />
|
>
|
||||||
</td>
|
<Trash2 className="h-4 w-4" />
|
||||||
<td className="px-4 py-3">
|
</button>
|
||||||
<div className="flex justify-end gap-1">
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</li>
|
||||||
className="rounded-lg p-1.5 text-ink-muted hover:bg-accent-soft hover:text-accent"
|
)
|
||||||
aria-label={t('knowledge.resync')}
|
})}
|
||||||
onClick={() => onSync(doc.id)}
|
</ul>
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg p-1.5 text-ink-muted hover:bg-danger-soft hover:text-danger"
|
|
||||||
aria-label={t('knowledge.deleteDocument')}
|
|
||||||
onClick={() => onDelete(doc.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function docMark(type: string): { icon: LucideIcon; wrap: string } {
|
||||||
|
const key = type.toLowerCase()
|
||||||
|
if (key.includes('url') || key.includes('web') || key.includes('html')) {
|
||||||
|
return { icon: Globe, wrap: 'bg-sky-50 text-sky-700' }
|
||||||
|
}
|
||||||
|
if (key.includes('text') || key === 'txt' || key === 'md' || key.includes('markdown')) {
|
||||||
|
return { icon: Type, wrap: 'bg-amber-50 text-amber-800' }
|
||||||
|
}
|
||||||
|
if (key.includes('pdf')) return { icon: FileText, wrap: 'bg-rose-50 text-rose-700' }
|
||||||
|
return { icon: FileText, wrap: 'bg-violet-50 text-violet-700' }
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { ArrowRight, BookOpen, Plus } from 'lucide-react'
|
import { ArrowRight, BookOpen, Check, FileText, Layers, Plus, Search, type LucideIcon } from 'lucide-react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { Button } from '../../components/Button'
|
import { Button } from '../../components/Button'
|
||||||
import { PageShell } from '../../components/ui/PageShell'
|
import { PageShell } from '../../components/ui/PageShell'
|
||||||
import { PageHeader } from '../../components/ui/PageHeader'
|
import { PageHeader } from '../../components/ui/PageHeader'
|
||||||
import { SegmentedTabs } from '../../components/ui/SegmentedTabs'
|
import { SegmentedTabs } from '../../components/ui/SegmentedTabs'
|
||||||
|
import { TableSkeleton } from '../../components/ui/Skeleton'
|
||||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
@@ -64,9 +65,14 @@ export function KnowledgeDetail() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [live, id])
|
}, [live, id])
|
||||||
|
|
||||||
|
const readyDocs = documents.filter((doc) => String(doc.status).toUpperCase() === 'FINISHED').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<Link to="/knowledge" className="mb-4 inline-flex items-center gap-1 text-sm text-ink-muted hover:text-accent">
|
<Link
|
||||||
|
to="/knowledge"
|
||||||
|
className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-sm text-ink-muted shadow-card transition hover:text-accent"
|
||||||
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
{t('knowledge.back')}
|
{t('knowledge.back')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -74,16 +80,7 @@ export function KnowledgeDetail() {
|
|||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
title={space?.name || t('knowledge.title')}
|
title={space?.name || t('knowledge.title')}
|
||||||
subtitle={space?.description || t('knowledge.detailSubtitle')}
|
subtitle={space?.description || t('knowledge.detailSubtitle')}
|
||||||
meta={
|
meta={space ? <StatusBadge status={space.status} label={spaceStatusLabel(space.status)} /> : null}
|
||||||
space ? (
|
|
||||||
<>
|
|
||||||
<StatusBadge status={space.status} label={spaceStatusLabel(space.status)} />
|
|
||||||
<span className="text-xs text-ink-muted">
|
|
||||||
{space.document_count} {t('knowledge.documents')} ? {space.chunk_count} {t('knowledge.chunks')}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
action={
|
action={
|
||||||
<Button onClick={() => setAdding(true)}>
|
<Button onClick={() => setAdding(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -91,17 +88,45 @@ export function KnowledgeDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{error ? <p className="mb-4 text-sm text-danger">{error}</p> : null}
|
{error ? (
|
||||||
|
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-ink-muted">{t('knowledge.loading')}</p>
|
<TableSkeleton rows={4} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-5 grid gap-3 sm:grid-cols-3">
|
||||||
|
<DetailMetric
|
||||||
|
label={t('knowledge.statDocuments')}
|
||||||
|
value={space?.document_count ?? documents.length}
|
||||||
|
icon={FileText}
|
||||||
|
wrap="bg-emerald-50 text-emerald-700"
|
||||||
|
active={tab === 'documents'}
|
||||||
|
onClick={() => setTab('documents')}
|
||||||
|
/>
|
||||||
|
<DetailMetric
|
||||||
|
label={t('knowledge.statChunks')}
|
||||||
|
value={space?.chunk_count ?? 0}
|
||||||
|
icon={Layers}
|
||||||
|
wrap="bg-sky-50 text-sky-800"
|
||||||
|
active={tab === 'chunks'}
|
||||||
|
onClick={() => setTab('chunks')}
|
||||||
|
/>
|
||||||
|
<DetailMetric
|
||||||
|
label={t('knowledge.statReady')}
|
||||||
|
value={readyDocs}
|
||||||
|
icon={Check}
|
||||||
|
wrap="bg-violet-50 text-violet-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<SegmentedTabs
|
<SegmentedTabs
|
||||||
items={[
|
items={[
|
||||||
['documents', t('knowledge.documentsTab')],
|
{ id: 'documents', label: t('knowledge.documentsTab'), icon: FileText },
|
||||||
['chunks', t('knowledge.chunksTab')],
|
{ id: 'chunks', label: t('knowledge.chunksTab'), icon: Layers },
|
||||||
['retrieve', t('knowledge.retrieveTab')],
|
{ id: 'retrieve', label: t('knowledge.retrieveTab'), icon: Search },
|
||||||
]}
|
]}
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -110,6 +135,7 @@ export function KnowledgeDetail() {
|
|||||||
{tab === 'documents' ? (
|
{tab === 'documents' ? (
|
||||||
<DocumentTable
|
<DocumentTable
|
||||||
documents={documents}
|
documents={documents}
|
||||||
|
onAdd={() => setAdding(true)}
|
||||||
onSync={(docId) => {
|
onSync={(docId) => {
|
||||||
void syncKnowledgeDocument(id, docId)
|
void syncKnowledgeDocument(id, docId)
|
||||||
.then(() => load())
|
.then(() => load())
|
||||||
@@ -123,10 +149,39 @@ export function KnowledgeDetail() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{tab === 'chunks' ? (
|
{tab === 'chunks' ? (
|
||||||
selectedDoc ? (
|
documents.length === 0 ? (
|
||||||
<ChunkBrowser spaceId={id} documentId={selectedDoc} />
|
<p className="rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
|
||||||
|
{t('knowledge.chunksEmpty')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
|
<>
|
||||||
|
{documents.length > 1 ? (
|
||||||
|
<div className="mb-4 flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{documents.map((doc) => {
|
||||||
|
const active = doc.id === selectedDoc
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedDoc(doc.id)}
|
||||||
|
className={`max-w-[220px] shrink-0 truncate rounded-full px-3 py-1.5 text-[13px] transition ${
|
||||||
|
active
|
||||||
|
? 'bg-accent-soft font-medium text-accent'
|
||||||
|
: 'bg-panel text-ink-muted ring-1 ring-line hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{doc.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedDoc ? (
|
||||||
|
<ChunkBrowser spaceId={id} documentId={selectedDoc} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink-muted">{t('knowledge.chunksEmpty')}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{tab === 'retrieve' ? <RecallTestPanel spaceId={id} /> : null}
|
{tab === 'retrieve' ? <RecallTestPanel spaceId={id} /> : null}
|
||||||
@@ -162,3 +217,38 @@ export function KnowledgeDetail() {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailMetric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
wrap,
|
||||||
|
active = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
icon: LucideIcon
|
||||||
|
wrap: string
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
const className = `rounded-2xl border bg-panel p-4 text-right shadow-card transition ${
|
||||||
|
active ? 'border-accent/40' : 'border-line'
|
||||||
|
} ${onClick ? 'hover:-translate-y-0.5 hover:border-line-strong' : ''}`
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${wrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-ink-muted">{label}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
if (!onClick) return <div className={className}>{body}</div>
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { BookOpen, Plus, Search, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
BookOpen,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Globe,
|
||||||
|
Layers,
|
||||||
|
MessageSquare,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Type,
|
||||||
|
Upload,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Button } from '../../components/Button'
|
import { Button } from '../../components/Button'
|
||||||
import { PageShell } from '../../components/ui/PageShell'
|
import { PageShell } from '../../components/ui/PageShell'
|
||||||
import { PageHeader } from '../../components/ui/PageHeader'
|
import { PageHeader } from '../../components/ui/PageHeader'
|
||||||
import { EmptyState } from '../../components/ui/EmptyState'
|
|
||||||
import { CardGridSkeleton } from '../../components/ui/Skeleton'
|
import { CardGridSkeleton } from '../../components/ui/Skeleton'
|
||||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||||
@@ -13,14 +25,29 @@ import { t } from '../../i18n'
|
|||||||
import { createKnowledgeSpace, deleteKnowledgeSpace, listKnowledgeSpaces } from './api'
|
import { createKnowledgeSpace, deleteKnowledgeSpace, listKnowledgeSpaces } from './api'
|
||||||
import { CreateSpaceModal } from './CreateSpaceModal'
|
import { CreateSpaceModal } from './CreateSpaceModal'
|
||||||
import { spaceStatusLabel } from './status'
|
import { spaceStatusLabel } from './status'
|
||||||
|
import type { SourceKind } from './types'
|
||||||
import type { KnowledgeSpace } from '../../contracts/knowledge'
|
import type { KnowledgeSpace } from '../../contracts/knowledge'
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ icon: BookOpen, title: 'knowledge.stepSpace', hint: 'knowledge.stepSpaceHint', wrap: 'bg-accent-soft text-accent' },
|
||||||
|
{ icon: FileText, title: 'knowledge.stepSource', hint: 'knowledge.stepSourceHint', wrap: 'bg-emerald-50 text-emerald-700' },
|
||||||
|
{ icon: MessageSquare, title: 'knowledge.stepChat', hint: 'knowledge.stepChatHint', wrap: 'bg-violet-50 text-violet-700' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const SOURCE_CARDS: Array<{ id: SourceKind; icon: LucideIcon; label: string; hint: string; wrap: string }> = [
|
||||||
|
{ id: 'file', icon: Upload, label: 'knowledge.sourceFile', hint: 'knowledge.sourceFileHint', wrap: 'bg-emerald-50 text-emerald-700' },
|
||||||
|
{ id: 'library', icon: FolderOpen, label: 'knowledge.sourceLibrary', hint: 'knowledge.sourceLibraryHint', wrap: 'bg-sky-50 text-sky-800' },
|
||||||
|
{ id: 'url', icon: Globe, label: 'knowledge.sourceUrl', hint: 'knowledge.sourceUrlHint', wrap: 'bg-amber-50 text-amber-800' },
|
||||||
|
{ id: 'text', icon: Type, label: 'knowledge.sourceText', hint: 'knowledge.sourceTextHint', wrap: 'bg-violet-50 text-violet-700' },
|
||||||
|
]
|
||||||
|
|
||||||
export function KnowledgeList() {
|
export function KnowledgeList() {
|
||||||
const [items, setItems] = useState<KnowledgeSpace[]>([])
|
const [items, setItems] = useState<KnowledgeSpace[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createKind, setCreateKind] = useState<SourceKind | undefined>(undefined)
|
||||||
const [deleting, setDeleting] = useState<KnowledgeSpace | null>(null)
|
const [deleting, setDeleting] = useState<KnowledgeSpace | null>(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -45,6 +72,20 @@ export function KnowledgeList() {
|
|||||||
return `${item.name} ${item.description || ''}`.toLowerCase().includes(needle)
|
return `${item.name} ${item.description || ''}`.toLowerCase().includes(needle)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totals = useMemo(
|
||||||
|
() => ({
|
||||||
|
documents: items.reduce((sum, item) => sum + item.document_count, 0),
|
||||||
|
chunks: items.reduce((sum, item) => sum + item.chunk_count, 0),
|
||||||
|
ready: items.filter((item) => item.status === 'ready').length,
|
||||||
|
}),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
function openCreate(kind?: SourceKind) {
|
||||||
|
setCreateKind(kind)
|
||||||
|
setCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -52,78 +93,101 @@ export function KnowledgeList() {
|
|||||||
title={t('knowledge.title')}
|
title={t('knowledge.title')}
|
||||||
subtitle={t('knowledge.subtitle')}
|
subtitle={t('knowledge.subtitle')}
|
||||||
action={
|
action={
|
||||||
<Button onClick={() => setCreating(true)}>
|
<Button onClick={() => openCreate()}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t('knowledge.create')}
|
{t('knowledge.create')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="mb-5">
|
{error ? (
|
||||||
<Input
|
<p className="mb-4 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
|
||||||
value={query}
|
{error}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
</p>
|
||||||
placeholder={t('knowledge.search')}
|
) : null}
|
||||||
leadingIcon={<Search className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error ? <p className="mb-4 text-sm text-danger">{error}</p> : null}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<CardGridSkeleton />
|
<CardGridSkeleton />
|
||||||
) : filtered.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyGuide onCreate={openCreate} />
|
||||||
icon={BookOpen}
|
|
||||||
title={t('knowledge.emptyTitle')}
|
|
||||||
description={t('knowledge.emptyDescription')}
|
|
||||||
>
|
|
||||||
<Button onClick={() => setCreating(true)}>{t('knowledge.create')}</Button>
|
|
||||||
</EmptyState>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<>
|
||||||
{filtered.map((item) => (
|
<div className="mb-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Link
|
<Metric label={t('knowledge.statSpaces')} value={items.length} icon={BookOpen} wrap="bg-accent-soft text-accent" />
|
||||||
key={item.id}
|
<Metric label={t('knowledge.statDocuments')} value={totals.documents} icon={FileText} wrap="bg-emerald-50 text-emerald-700" />
|
||||||
to={`/knowledge/${item.id}`}
|
<Metric label={t('knowledge.statChunks')} value={totals.chunks} icon={Layers} wrap="bg-sky-50 text-sky-800" />
|
||||||
className="rounded-2xl border border-line bg-panel p-4 transition hover:border-line-strong"
|
<Metric label={t('knowledge.statReady')} value={totals.ready} icon={BookOpen} wrap="bg-violet-50 text-violet-700" />
|
||||||
>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="mb-5 max-w-md">
|
||||||
<span className="rounded-xl bg-accent-soft p-2 text-accent">
|
<Input
|
||||||
<BookOpen className="h-5 w-5" />
|
value={query}
|
||||||
</span>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
<div className="min-w-0 flex-1">
|
placeholder={t('knowledge.search')}
|
||||||
<div className="flex items-start justify-between gap-2">
|
leadingIcon={<Search className="h-4 w-4" />}
|
||||||
<h3 className="font-semibold text-ink">{item.name}</h3>
|
/>
|
||||||
<StatusBadge status={item.status} label={spaceStatusLabel(item.status)} />
|
</div>
|
||||||
</div>
|
{filtered.length === 0 ? (
|
||||||
{item.description ? (
|
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-14 text-center text-sm text-ink-muted shadow-card">
|
||||||
<p className="mt-1 line-clamp-2 text-sm text-ink-muted">{item.description}</p>
|
{t('knowledge.noMatches')}
|
||||||
) : null}
|
</div>
|
||||||
<p className="mt-2 text-xs text-ink-subtle">
|
) : (
|
||||||
{item.document_count} {t('knowledge.documents')} ? {item.chunk_count}{' '}
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{t('knowledge.chunks')}
|
{filtered.map((item) => (
|
||||||
</p>
|
<Link
|
||||||
</div>
|
key={item.id}
|
||||||
<button
|
to={`/knowledge/${item.id}`}
|
||||||
type="button"
|
className="group relative overflow-hidden rounded-2xl border border-line bg-panel p-4 shadow-card transition hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_16px_32px_-20px_rgba(23,32,51,0.45)]"
|
||||||
className="rounded-lg p-1.5 text-ink-muted hover:bg-danger-soft hover:text-danger"
|
|
||||||
aria-label={t('knowledge.delete')}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
setDeleting(item)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<span className="pointer-events-none absolute -top-8 -start-6 h-24 w-24 rounded-full bg-accent/15 blur-2xl" />
|
||||||
</button>
|
<div className="relative flex items-start gap-3">
|
||||||
</div>
|
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent-soft text-accent">
|
||||||
</Link>
|
<BookOpen className="h-5 w-5" />
|
||||||
))}
|
</span>
|
||||||
</div>
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="truncate font-semibold text-ink">{item.name}</h3>
|
||||||
|
<StatusBadge status={item.status} label={spaceStatusLabel(item.status)} />
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm leading-6 text-ink-muted">{item.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-sm text-ink-subtle">{t('knowledge.detailSubtitle')}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-3 flex flex-wrap gap-2 text-[12px] text-ink-muted">
|
||||||
|
<span className="rounded-full bg-surface-subtle px-2.5 py-1">
|
||||||
|
{item.document_count} {t('knowledge.documents')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-surface-subtle px-2.5 py-1">
|
||||||
|
{item.chunk_count} {t('knowledge.chunks')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg p-1.5 text-ink-muted transition hover:bg-danger-soft hover:text-danger"
|
||||||
|
aria-label={t('knowledge.delete')}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setDeleting(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<CreateSpaceModal
|
<CreateSpaceModal
|
||||||
open={creating}
|
open={creating}
|
||||||
|
initialKind={createKind}
|
||||||
title={t('knowledge.create')}
|
title={t('knowledge.create')}
|
||||||
submitLabel={t('knowledge.create')}
|
submitLabel={t('knowledge.create')}
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => {
|
||||||
|
setCreating(false)
|
||||||
|
setCreateKind(undefined)
|
||||||
|
}}
|
||||||
onSubmit={async (payload) => {
|
onSubmit={async (payload) => {
|
||||||
await createKnowledgeSpace(payload)
|
await createKnowledgeSpace(payload)
|
||||||
await load()
|
await load()
|
||||||
@@ -148,3 +212,86 @@ export function KnowledgeList() {
|
|||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
wrap,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
icon: LucideIcon
|
||||||
|
wrap: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-line bg-panel p-4 shadow-card">
|
||||||
|
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-xl ${wrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight text-ink">{value}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-ink-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyGuide({ onCreate }: { onCreate: (kind?: SourceKind) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="overflow-hidden rounded-3xl border border-line bg-panel shadow-card">
|
||||||
|
<div className="bg-[radial-gradient(circle_at_top_left,rgba(37,99,235,0.14),transparent_46%),linear-gradient(180deg,#f8fbff,#ffffff)] px-5 py-6 md:px-7">
|
||||||
|
<p className="text-[11px] font-semibold text-accent">{t('knowledge.emptyKicker')}</p>
|
||||||
|
<h2 className="mt-1 text-xl font-bold tracking-tight text-ink">{t('knowledge.emptyTitle')}</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-7 text-ink-muted">{t('knowledge.emptyDescription')}</p>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Button onClick={() => onCreate()}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t('knowledge.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ol className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||||
|
{STEPS.map((step, index) => {
|
||||||
|
const Icon = step.icon
|
||||||
|
return (
|
||||||
|
<li key={step.title} className="flex items-start gap-3 rounded-2xl border border-line bg-white/80 p-3">
|
||||||
|
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${step.wrap}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-[11px] text-ink-subtle">{index + 1}</span>
|
||||||
|
<span className="block text-sm font-semibold text-ink">{t(step.title)}</span>
|
||||||
|
<span className="mt-0.5 block text-[12px] leading-5 text-ink-muted">{t(step.hint)}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-ink">{t('knowledge.suggestSources')}</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{SOURCE_CARDS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreate(item.id)}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-line bg-panel px-4 py-4 text-right shadow-card transition hover:-translate-y-0.5 hover:border-line-strong"
|
||||||
|
>
|
||||||
|
<span className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ${item.wrap}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold text-ink">{t(item.label)}</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[12px] text-ink-muted">{t(item.hint)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import { Button } from '../../components/Button'
|
import { Button } from '../../components/Button'
|
||||||
import { LabeledInput } from '../../components/ui/Field'
|
import { LabeledInput } from '../../components/ui/Field'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
@@ -10,6 +11,7 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) {
|
|||||||
const [topK, setTopK] = useState('5')
|
const [topK, setTopK] = useState('5')
|
||||||
const [hits, setHits] = useState<KnowledgeRetrieveHit[]>([])
|
const [hits, setHits] = useState<KnowledgeRetrieveHit[]>([])
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [ran, setRan] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -22,9 +24,11 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) {
|
|||||||
top_k: Number(topK) || 5,
|
top_k: Number(topK) || 5,
|
||||||
})
|
})
|
||||||
setHits(next)
|
setHits(next)
|
||||||
|
setRan(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('knowledge.retrieveFailed'))
|
setError(err instanceof Error ? err.message : t('knowledge.retrieveFailed'))
|
||||||
setHits([])
|
setHits([])
|
||||||
|
setRan(true)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -32,36 +36,58 @@ export function RecallTestPanel({ spaceId }: { spaceId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_120px_auto]">
|
<form
|
||||||
<LabeledInput
|
className="rounded-2xl border border-line bg-panel p-4 shadow-card"
|
||||||
label={t('knowledge.retrieveQuery')}
|
onSubmit={(event) => {
|
||||||
value={query}
|
event.preventDefault()
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
void run()
|
||||||
/>
|
}}
|
||||||
<LabeledInput
|
>
|
||||||
label={t('knowledge.topK')}
|
<p className="mb-4 text-[13px] leading-6 text-ink-muted">{t('knowledge.retrieveLead')}</p>
|
||||||
value={topK}
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_120px_auto] md:items-end">
|
||||||
onChange={(event) => setTopK(event.target.value)}
|
<LabeledInput
|
||||||
/>
|
label={t('knowledge.retrieveQuery')}
|
||||||
<div className="flex items-end">
|
value={query}
|
||||||
<Button onClick={() => void run()} disabled={busy || !query.trim()}>
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<LabeledInput
|
||||||
|
label={t('knowledge.topK')}
|
||||||
|
value={topK}
|
||||||
|
onChange={(event) => setTopK(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={busy || !query.trim()}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
{t('knowledge.runRetrieve')}
|
{t('knowledge.runRetrieve')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
{error ? <p className="mt-3 text-sm text-danger">{error}</p> : null}
|
{error ? (
|
||||||
|
<p className="mt-3 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{hits.map((hit, index) => (
|
{hits.map((hit, index) => {
|
||||||
<article key={hit.chunk_id || index} className="rounded-xl border border-line bg-panel p-4">
|
const percent = hit.score >= 0 && hit.score <= 1 ? Math.round(hit.score * 100) : null
|
||||||
<div className="mb-2 flex items-center justify-between text-xs text-ink-muted">
|
return (
|
||||||
<span>{hit.doc_name || t('knowledge.unknownDoc')}</span>
|
<article key={hit.chunk_id || index} className="rounded-2xl border border-line bg-panel p-4 shadow-card">
|
||||||
<span>{hit.score.toFixed(3)}</span>
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-ink-muted">
|
||||||
</div>
|
<span className="truncate font-medium text-ink">{hit.doc_name || t('knowledge.unknownDoc')}</span>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{hit.content}</p>
|
<span className="tabular-nums">{hit.score.toFixed(3)}</span>
|
||||||
</article>
|
</div>
|
||||||
))}
|
{percent !== null ? (
|
||||||
{!busy && hits.length === 0 ? (
|
<span className="mb-3 block h-1.5 overflow-hidden rounded-full bg-surface-subtle">
|
||||||
<p className="text-sm text-ink-muted">{t('knowledge.retrieveEmpty')}</p>
|
<span className="block h-full rounded-full bg-accent" style={{ width: `${percent}%` }} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-7 text-ink">{hit.content}</p>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!busy && ran && hits.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-line bg-panel px-6 py-12 text-center text-sm text-ink-muted shadow-card">
|
||||||
|
{t('knowledge.retrieveEmpty')}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+55
-6
@@ -142,6 +142,23 @@ export const en = {
|
|||||||
search: 'Search spaces',
|
search: 'Search spaces',
|
||||||
emptyTitle: 'No knowledge spaces yet',
|
emptyTitle: 'No knowledge spaces yet',
|
||||||
emptyDescription: 'Create a space and add a file, URL, or text.',
|
emptyDescription: 'Create a space and add a file, URL, or text.',
|
||||||
|
emptyKicker: 'Get started',
|
||||||
|
stepSpace: 'Space',
|
||||||
|
stepSpaceHint: 'Name one topic for the organization',
|
||||||
|
stepSource: 'Document',
|
||||||
|
stepSourceHint: 'A file, web page, or text',
|
||||||
|
stepChat: 'Chat',
|
||||||
|
stepChatHint: 'Pick this space next to a question',
|
||||||
|
suggestSources: 'Choose a source',
|
||||||
|
statSpaces: 'Spaces',
|
||||||
|
statDocuments: 'Documents',
|
||||||
|
statChunks: 'Chunks',
|
||||||
|
statReady: 'Ready',
|
||||||
|
noMatches: 'No space matches this search.',
|
||||||
|
documentsEmptyTitle: 'This space has no documents yet',
|
||||||
|
documentsEmptyBody: 'Add a file, URL, or text so chunks can be built and searched in chat.',
|
||||||
|
chunksLead: 'The document is split into pieces that chat uses to answer.',
|
||||||
|
retrieveLead: 'Ask a question to see which pieces come back.',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
loadFailed: 'Could not load knowledge spaces.',
|
loadFailed: 'Could not load knowledge spaces.',
|
||||||
saveFailed: 'Could not save.',
|
saveFailed: 'Could not save.',
|
||||||
@@ -222,10 +239,10 @@ export const en = {
|
|||||||
},
|
},
|
||||||
databases: {
|
databases: {
|
||||||
pageTitle: 'Databases',
|
pageTitle: 'Databases',
|
||||||
pageSubtitle: 'Connect MySQL, PostgreSQL, Oracle, SQL Server, SQLite, Neo4j, and MongoDB, plus Excel and CSV tables.',
|
pageSubtitle: 'Connect relational, file, graph, and document engines. Each one has its own test-and-save form.',
|
||||||
connectTitle: 'Configure connection',
|
connectTitle: 'Configure connection',
|
||||||
editTitle: 'Edit connection',
|
editTitle: 'Edit connection',
|
||||||
connectHint: 'Connect MySQL, PostgreSQL, Oracle, SQL Server, SQLite, Neo4j, or MongoDB to Kamidso.',
|
connectHint: 'Pick an engine, test the connection, then save it.',
|
||||||
connect: 'New connection',
|
connect: 'New connection',
|
||||||
engine: 'Database engine',
|
engine: 'Database engine',
|
||||||
name: 'Display name',
|
name: 'Display name',
|
||||||
@@ -236,10 +253,14 @@ export const en = {
|
|||||||
fieldPassword: 'Password',
|
fieldPassword: 'Password',
|
||||||
fieldDatabase: 'Database',
|
fieldDatabase: 'Database',
|
||||||
fieldSchema: 'Schema',
|
fieldSchema: 'Schema',
|
||||||
fieldPath: 'SQLite file path',
|
fieldPath: 'File path',
|
||||||
fieldServiceName: 'Service name',
|
fieldServiceName: 'Service name',
|
||||||
fieldSid: 'SID',
|
fieldSid: 'SID',
|
||||||
fieldAuthSource: 'Auth source',
|
fieldAuthSource: 'Auth source',
|
||||||
|
fieldEndpoint: 'Endpoint',
|
||||||
|
fieldProject: 'Project',
|
||||||
|
fieldAccessId: 'AccessKey ID',
|
||||||
|
fieldAccessKey: 'AccessKey Secret',
|
||||||
passwordKeep: 'Leave blank to keep the stored password.',
|
passwordKeep: 'Leave blank to keep the stored password.',
|
||||||
test: 'Test connection',
|
test: 'Test connection',
|
||||||
testOk: 'Connection succeeded.',
|
testOk: 'Connection succeeded.',
|
||||||
@@ -277,10 +298,22 @@ export const en = {
|
|||||||
kindGraph: 'Graph',
|
kindGraph: 'Graph',
|
||||||
kindDocument: 'Document',
|
kindDocument: 'Document',
|
||||||
blurbMysql: 'The most common engine for web and operational systems.',
|
blurbMysql: 'The most common engine for web and operational systems.',
|
||||||
|
blurbMariadb: 'MySQL-compatible, for open-source operational systems.',
|
||||||
|
blurbOceanbase: 'Distributed SQL with the MySQL protocol.',
|
||||||
blurbPostgres: 'Analytics, reporting, and structured schemas.',
|
blurbPostgres: 'Analytics, reporting, and structured schemas.',
|
||||||
blurbOracle: 'Enterprise systems; Service Name or SID.',
|
blurbOracle: 'Enterprise systems; Service Name or SID.',
|
||||||
blurbMssql: 'Microsoft environments and SQL Server warehouses.',
|
blurbMssql: 'Microsoft environments and SQL Server warehouses.',
|
||||||
blurbSqlite: 'A local file, with no separate server.',
|
blurbSqlite: 'A local file, with no separate server.',
|
||||||
|
blurbDuckdb: 'Columnar analytics on a local file.',
|
||||||
|
blurbClickhouse: 'Fast columnar analytics for large reports.',
|
||||||
|
blurbStarrocks: 'Analytical warehouse with the MySQL protocol.',
|
||||||
|
blurbDoris: 'Real-time warehouse, MySQL-compatible.',
|
||||||
|
blurbHive: 'Hadoop warehouse; default port 10000.',
|
||||||
|
blurbGaussdb: 'Enterprise database with PostgreSQL compatibility.',
|
||||||
|
blurbOpengauss: 'Open-source database with PostgreSQL compatibility.',
|
||||||
|
blurbVertica: 'Columnar warehouse for large-scale analytics.',
|
||||||
|
blurbMaxcompute: 'Alibaba Cloud managed warehouse (ODPS).',
|
||||||
|
blurbSpark: 'A data file for Spark SQL (parquet, csv, json, …).',
|
||||||
blurbNeo4j: 'Relationships and networks with Cypher.',
|
blurbNeo4j: 'Relationships and networks with Cypher.',
|
||||||
blurbMongodb: 'JSON documents for query and aggregation.',
|
blurbMongodb: 'JSON documents for query and aggregation.',
|
||||||
hintMysqlHost: 'Example: 127.0.0.1 or db.internal',
|
hintMysqlHost: 'Example: 127.0.0.1 or db.internal',
|
||||||
@@ -294,6 +327,17 @@ export const en = {
|
|||||||
hintOracleSid: 'Instance SID if you do not use a service name',
|
hintOracleSid: 'Instance SID if you do not use a service name',
|
||||||
hintMssqlPort: 'Default 1433',
|
hintMssqlPort: 'Default 1433',
|
||||||
hintSqlitePath: 'Example: /app/pilot/examples/Walmart_Sales.db — the Docker example path is resolved locally.',
|
hintSqlitePath: 'Example: /app/pilot/examples/Walmart_Sales.db — the Docker example path is resolved locally.',
|
||||||
|
hintDuckdbPath: 'Path to a .duckdb file on the server.',
|
||||||
|
hintOceanbasePort: 'Default 2881',
|
||||||
|
hintClickhousePort: 'HTTP is usually 8123',
|
||||||
|
hintStarrocksPort: 'MySQL protocol port is usually 9030',
|
||||||
|
hintDorisPort: 'MySQL protocol port is usually 9030',
|
||||||
|
hintHivePort: 'HiveServer2 is usually 10000',
|
||||||
|
hintHiveDatabase: 'Defaults to default if empty',
|
||||||
|
hintVerticaPort: 'Default 5433',
|
||||||
|
hintMaxcomputeEndpoint: 'Regional API URL, e.g. cn-hangzhou',
|
||||||
|
hintMaxcomputeProject: 'MaxCompute project name',
|
||||||
|
hintSparkPath: 'Server path to parquet / csv / json / orc.',
|
||||||
hintNeo4jPort: 'Bolt is usually 7687',
|
hintNeo4jPort: 'Bolt is usually 7687',
|
||||||
hintNeo4jDatabase: 'Default neo4j',
|
hintNeo4jDatabase: 'Default neo4j',
|
||||||
hintMongoPort: 'Default 27017',
|
hintMongoPort: 'Default 27017',
|
||||||
@@ -361,10 +405,15 @@ export const en = {
|
|||||||
savedTitle: 'Saved connections',
|
savedTitle: 'Saved connections',
|
||||||
savedEmptyTitle: 'No connection saved yet',
|
savedEmptyTitle: 'No connection saved yet',
|
||||||
savedEmptyBody: 'Pick an engine and enter the access details.',
|
savedEmptyBody: 'Pick an engine and enter the access details.',
|
||||||
addEngineTitle: 'New engine',
|
addEngineTitle: 'Add a database',
|
||||||
addEngineHint: 'Each engine has its own settings form.',
|
addEngineHint: 'Pick an engine from the catalog. Its settings, test, and save form opens separately.',
|
||||||
|
engineSearch: 'Search engines',
|
||||||
|
engineNoMatches: 'No engine matches this filter.',
|
||||||
|
groupAll: 'All',
|
||||||
groupSql: 'Relational',
|
groupSql: 'Relational',
|
||||||
groupOther: 'File, graph, and document',
|
groupWarehouse: 'Warehouse and analytics',
|
||||||
|
groupFile: 'Local file',
|
||||||
|
groupOther: 'Graph and document',
|
||||||
manageOnly: 'Only a workspace admin can create or edit connections.',
|
manageOnly: 'Only a workspace admin can create or edit connections.',
|
||||||
connectionCount: '{count} connections',
|
connectionCount: '{count} connections',
|
||||||
openMap: 'Discover structure and links on the organization map',
|
openMap: 'Discover structure and links on the organization map',
|
||||||
|
|||||||
+55
-6
@@ -169,6 +169,23 @@ export const fa = {
|
|||||||
search: 'جستوجوی فضا',
|
search: 'جستوجوی فضا',
|
||||||
emptyTitle: 'هنوز فضای دانشی نیست',
|
emptyTitle: 'هنوز فضای دانشی نیست',
|
||||||
emptyDescription: 'یک فضا بسازید و فایل، نشانی وب یا متن اضافه کنید.',
|
emptyDescription: 'یک فضا بسازید و فایل، نشانی وب یا متن اضافه کنید.',
|
||||||
|
emptyKicker: 'شروع کار',
|
||||||
|
stepSpace: 'فضا',
|
||||||
|
stepSpaceHint: 'یک موضوع سازمان را نامگذاری کنید',
|
||||||
|
stepSource: 'سند',
|
||||||
|
stepSourceHint: 'فایل، صفحه وب یا متن',
|
||||||
|
stepChat: 'گفتگو',
|
||||||
|
stepChatHint: 'همین فضا را کنار پرسش انتخاب کنید',
|
||||||
|
suggestSources: 'منبع را انتخاب کنید',
|
||||||
|
statSpaces: 'فضا',
|
||||||
|
statDocuments: 'سند',
|
||||||
|
statChunks: 'قطعه',
|
||||||
|
statReady: 'آماده',
|
||||||
|
noMatches: 'فضایی با این مشخصات پیدا نشد.',
|
||||||
|
documentsEmptyTitle: 'این فضا هنوز سندی ندارد',
|
||||||
|
documentsEmptyBody: 'یک فایل، نشانی وب یا متن اضافه کنید تا قطعهها ساخته شوند و در گفتگو قابل جستوجو باشند.',
|
||||||
|
chunksLead: 'متن سند به قطعههایی بریده شده که گفتگو از آنها جواب میگیرد.',
|
||||||
|
retrieveLead: 'پرسشی بنویسید تا ببینید کدام قطعهها به آن نزدیکاند.',
|
||||||
loading: 'در حال بارگذاری…',
|
loading: 'در حال بارگذاری…',
|
||||||
loadFailed: 'بارگذاری فضاهای دانش ناموفق بود.',
|
loadFailed: 'بارگذاری فضاهای دانش ناموفق بود.',
|
||||||
saveFailed: 'ذخیره ناموفق بود.',
|
saveFailed: 'ذخیره ناموفق بود.',
|
||||||
@@ -249,10 +266,10 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
databases: {
|
databases: {
|
||||||
pageTitle: 'پایگاه داده',
|
pageTitle: 'پایگاه داده',
|
||||||
pageSubtitle: 'اتصال MySQL، PostgreSQL، Oracle، SQL Server، SQLite، Neo4j و MongoDB، بههمراه فایلهای جدولی Excel و CSV.',
|
pageSubtitle: 'اتصال پایگاههای رابطهای، فایلمحور، گراف و سندگرا؛ هر موتور فرم تست و ذخیرهٔ خودش را دارد.',
|
||||||
connectTitle: 'تنظیم اتصال',
|
connectTitle: 'تنظیم اتصال',
|
||||||
editTitle: 'ویرایش اتصال',
|
editTitle: 'ویرایش اتصال',
|
||||||
connectHint: 'MySQL، PostgreSQL، Oracle، SQL Server، SQLite، Neo4j و MongoDB را به کامیدسو وصل کنید.',
|
connectHint: 'موتور را انتخاب کنید، اتصال را بیازمایید و ذخیره کنید.',
|
||||||
connect: 'اتصال جدید',
|
connect: 'اتصال جدید',
|
||||||
engine: 'نوع پایگاه داده',
|
engine: 'نوع پایگاه داده',
|
||||||
name: 'نام نمایشی',
|
name: 'نام نمایشی',
|
||||||
@@ -263,10 +280,14 @@ export const fa = {
|
|||||||
fieldPassword: 'رمز عبور',
|
fieldPassword: 'رمز عبور',
|
||||||
fieldDatabase: 'نام پایگاه',
|
fieldDatabase: 'نام پایگاه',
|
||||||
fieldSchema: 'اسکیما',
|
fieldSchema: 'اسکیما',
|
||||||
fieldPath: 'مسیر فایل SQLite',
|
fieldPath: 'مسیر فایل',
|
||||||
fieldServiceName: 'Service name',
|
fieldServiceName: 'Service name',
|
||||||
fieldSid: 'SID',
|
fieldSid: 'SID',
|
||||||
fieldAuthSource: 'Auth source',
|
fieldAuthSource: 'Auth source',
|
||||||
|
fieldEndpoint: 'Endpoint',
|
||||||
|
fieldProject: 'Project',
|
||||||
|
fieldAccessId: 'AccessKey ID',
|
||||||
|
fieldAccessKey: 'AccessKey Secret',
|
||||||
passwordKeep: 'خالی بگذارید تا رمز ذخیرهشده حفظ شود.',
|
passwordKeep: 'خالی بگذارید تا رمز ذخیرهشده حفظ شود.',
|
||||||
test: 'آزمون اتصال',
|
test: 'آزمون اتصال',
|
||||||
testOk: 'اتصال برقرار شد.',
|
testOk: 'اتصال برقرار شد.',
|
||||||
@@ -304,10 +325,22 @@ export const fa = {
|
|||||||
kindGraph: 'گراف',
|
kindGraph: 'گراف',
|
||||||
kindDocument: 'سندگرا',
|
kindDocument: 'سندگرا',
|
||||||
blurbMysql: 'رایجترین پایگاه وب و سامانههای عملیاتی.',
|
blurbMysql: 'رایجترین پایگاه وب و سامانههای عملیاتی.',
|
||||||
|
blurbMariadb: 'سازگار با MySQL؛ سامانههای متنباز و عملیاتی.',
|
||||||
|
blurbOceanbase: 'دیتابیس توزیعشده سازگار با پروتکل MySQL.',
|
||||||
blurbPostgres: 'تحلیل، گزارش و دادههای ساختیافته با اسکیما.',
|
blurbPostgres: 'تحلیل، گزارش و دادههای ساختیافته با اسکیما.',
|
||||||
blurbOracle: 'سامانههای سازمانی؛ Service Name یا SID.',
|
blurbOracle: 'سامانههای سازمانی؛ Service Name یا SID.',
|
||||||
blurbMssql: 'محیطهای مایکروسافت و انبارهای SQL Server.',
|
blurbMssql: 'محیطهای مایکروسافت و انبارهای SQL Server.',
|
||||||
blurbSqlite: 'یک فایل محلی، بدون سرور جداگانه.',
|
blurbSqlite: 'یک فایل محلی، بدون سرور جداگانه.',
|
||||||
|
blurbDuckdb: 'تحلیل ستونی روی یک فایل محلی.',
|
||||||
|
blurbClickhouse: 'تحلیل سریع ستونی برای گزارشهای حجیم.',
|
||||||
|
blurbStarrocks: 'انبار تحلیلی با پروتکل MySQL.',
|
||||||
|
blurbDoris: 'انبار بلادرنگ سازگار با MySQL.',
|
||||||
|
blurbHive: 'انبار داده روی Hadoop؛ پورت پیشفرض 10000.',
|
||||||
|
blurbGaussdb: 'دیتابیس سازمانی سازگار با PostgreSQL.',
|
||||||
|
blurbOpengauss: 'دیتابیس متنباز سازگار با PostgreSQL.',
|
||||||
|
blurbVertica: 'انبار ستونی برای تحلیل در مقیاس بزرگ.',
|
||||||
|
blurbMaxcompute: 'انبار مدیریتشده علیبابا کلاد (ODPS).',
|
||||||
|
blurbSpark: 'فایل داده برای Spark SQL (parquet، csv، json، …).',
|
||||||
blurbNeo4j: 'روابط و شبکه با زبان Cypher.',
|
blurbNeo4j: 'روابط و شبکه با زبان Cypher.',
|
||||||
blurbMongodb: 'سندهای JSON برای جستوجو و تجمیع.',
|
blurbMongodb: 'سندهای JSON برای جستوجو و تجمیع.',
|
||||||
hintMysqlHost: 'مثال: 127.0.0.1 یا db.internal',
|
hintMysqlHost: 'مثال: 127.0.0.1 یا db.internal',
|
||||||
@@ -321,6 +354,17 @@ export const fa = {
|
|||||||
hintOracleSid: 'SID نمونه، اگر Service name ندارید',
|
hintOracleSid: 'SID نمونه، اگر Service name ندارید',
|
||||||
hintMssqlPort: 'پیشفرض 1433',
|
hintMssqlPort: 'پیشفرض 1433',
|
||||||
hintSqlitePath: 'مثال: /app/pilot/examples/Walmart_Sales.db — مسیر Docker هم روی ویندوز به نمونه والمارت وصل میشود.',
|
hintSqlitePath: 'مثال: /app/pilot/examples/Walmart_Sales.db — مسیر Docker هم روی ویندوز به نمونه والمارت وصل میشود.',
|
||||||
|
hintDuckdbPath: 'مسیر فایل .duckdb روی سرور.',
|
||||||
|
hintOceanbasePort: 'پیشفرض 2881',
|
||||||
|
hintClickhousePort: 'HTTP معمولاً 8123',
|
||||||
|
hintStarrocksPort: 'پورت MySQL معمولاً 9030',
|
||||||
|
hintDorisPort: 'پورت MySQL معمولاً 9030',
|
||||||
|
hintHivePort: 'HiveServer2 معمولاً 10000',
|
||||||
|
hintHiveDatabase: 'اگر خالی باشد default استفاده میشود',
|
||||||
|
hintVerticaPort: 'پیشفرض 5433',
|
||||||
|
hintMaxcomputeEndpoint: 'آدرس API منطقه، مثلاً cn-hangzhou',
|
||||||
|
hintMaxcomputeProject: 'نام پروژهٔ MaxCompute',
|
||||||
|
hintSparkPath: 'مسیر parquet / csv / json / orc روی سرور.',
|
||||||
hintNeo4jPort: 'Bolt معمولاً 7687',
|
hintNeo4jPort: 'Bolt معمولاً 7687',
|
||||||
hintNeo4jDatabase: 'پیشفرض neo4j',
|
hintNeo4jDatabase: 'پیشفرض neo4j',
|
||||||
hintMongoPort: 'پیشفرض 27017',
|
hintMongoPort: 'پیشفرض 27017',
|
||||||
@@ -388,10 +432,15 @@ export const fa = {
|
|||||||
savedTitle: 'اتصالهای ذخیرهشده',
|
savedTitle: 'اتصالهای ذخیرهشده',
|
||||||
savedEmptyTitle: 'هنوز اتصالی ذخیره نشده',
|
savedEmptyTitle: 'هنوز اتصالی ذخیره نشده',
|
||||||
savedEmptyBody: 'یک موتور را انتخاب کنید و مشخصات دسترسی را وارد کنید.',
|
savedEmptyBody: 'یک موتور را انتخاب کنید و مشخصات دسترسی را وارد کنید.',
|
||||||
addEngineTitle: 'موتور جدید',
|
addEngineTitle: 'افزودن پایگاه',
|
||||||
addEngineHint: 'هر موتور فرم تنظیمات خودش را دارد.',
|
addEngineHint: 'موتور را از ردیف بالا انتخاب کنید؛ فرم تنظیمات، آزمون و ذخیره جدا باز میشود.',
|
||||||
|
engineSearch: 'جستجوی موتور',
|
||||||
|
engineNoMatches: 'موتوری با این مشخصات پیدا نشد.',
|
||||||
|
groupAll: 'همه',
|
||||||
groupSql: 'رابطهای',
|
groupSql: 'رابطهای',
|
||||||
groupOther: 'فایل، گراف و سند',
|
groupWarehouse: 'انبار و تحلیلی',
|
||||||
|
groupFile: 'فایل محلی',
|
||||||
|
groupOther: 'گراف و سند',
|
||||||
manageOnly: 'ساخت و ویرایش اتصال برای مدیر فضای کاری است.',
|
manageOnly: 'ساخت و ویرایش اتصال برای مدیر فضای کاری است.',
|
||||||
connectionCount: '{count} اتصال',
|
connectionCount: '{count} اتصال',
|
||||||
openMap: 'کشف ساختار و ارتباطها در نقشه سازمان',
|
openMap: 'کشف ساختار و ارتباطها در نقشه سازمان',
|
||||||
|
|||||||
Reference in new issue
Block a user