diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index e14d01dc..cc6471fa 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -1,4 +1,5 @@ -import { Dialog } from '@clickhouse/click-ui'; +import { Dialog, Select } from '@clickhouse/click-ui'; +import { useTranslation } from 'react-i18next'; import type * as t from '@/types'; import { useTheme } from '@/contexts/ThemeContext'; import { useLocalize } from '@/hooks'; @@ -11,9 +12,26 @@ const THEME_LABEL_KEYS: Record = { dark: 'com_nav_theme_dark', }; +const LANGUAGE_OPTIONS = ['en', 'zh-Hans'] as const; +type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]; +const LANGUAGE_LABEL_KEYS: Record = { + en: 'com_ui_language_english', + 'zh-Hans': 'com_ui_language_zh_hans', +}; + export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { const localize = useLocalize(); const { theme, setTheme } = useTheme(); + const { i18n } = useTranslation(); + + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language ?? 'en'; + const currentLanguage: LanguageOption = resolvedLanguage.startsWith('zh') ? 'zh-Hans' : 'en'; + + const setLanguage = (language: LanguageOption) => { + i18n.changeLanguage(language); + localStorage.setItem('i18nextLng', language); + document.documentElement.lang = language; + }; return ( event.preventDefault()} title={localize('com_ui_settings')} showClose onClose={onClose} @@ -57,6 +76,26 @@ export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { ))} +
+
+ + {localize('com_ui_language')} + +
+
+ +
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ce99c1e2..18d6f09c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -31,6 +31,9 @@ const navItems: t.NavItem[] = [ capability: [SystemCapabilities.READ_ROLES, SystemCapabilities.READ_GROUPS], }, { labelKey: 'com_nav_grants', path: '/grants', icon: 'lock' }, + { labelKey: 'com_nav_tools', path: '/tools', icon: 'plug' }, + { labelKey: 'com_nav_usage', path: '/usage', icon: 'bar-chart' }, + { labelKey: 'com_nav_services', path: '/services', icon: 'server' }, { labelKey: 'com_nav_help', path: '/help', icon: 'question' }, ]; diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index f383777a..45b87c37 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -81,6 +81,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { }} > event.preventDefault()} title={localize('com_access_create_group')} showClose onClose={resetAndClose} diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 836f3534..8e9a4de8 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -84,6 +84,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { }} > event.preventDefault()} title={localize('com_access_create_role')} showClose onClose={resetAndClose} diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index 8c0d6cfb..b726d129 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -131,6 +131,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog }} > event.preventDefault()} title={localize('com_access_edit_group')} showClose onClose={onClose} diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 891c1ac2..0d48aece 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -177,6 +177,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro }} > event.preventDefault()} title={localize('com_access_edit_role')} showClose onClose={onClose} diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx index 6946b968..f8470525 100644 --- a/src/components/configuration/ConfirmSaveDialog.tsx +++ b/src/components/configuration/ConfirmSaveDialog.tsx @@ -29,6 +29,7 @@ export function ConfirmSaveDialog({ }} > event.preventDefault()} title={localize('com_config_confirm_save_title')} showClose onClose={onCancel} diff --git a/src/components/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index f67ce196..178022ce 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -21,6 +21,7 @@ export function DeleteProfileValueModal({ }} > event.preventDefault()} title={localize('com_scope_confirm_remove')} showClose onClose={onCancel} diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..3d841668 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -199,6 +199,7 @@ export function ImportYamlDialog({ }} > event.preventDefault()} title={localize('com_config_import_yaml_title')} showClose onClose={handleClose} diff --git a/src/components/configuration/ProfileIndicator.tsx b/src/components/configuration/ProfileIndicator.tsx index c7a499a9..6ebdcaa2 100644 --- a/src/components/configuration/ProfileIndicator.tsx +++ b/src/components/configuration/ProfileIndicator.tsx @@ -86,6 +86,7 @@ export function ProfileIndicator({ }} > event.preventDefault()} title={localize('com_scope_cascade_title')} showClose onClose={handleClose} diff --git a/src/components/configuration/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx index 560bbf65..52e6b152 100644 --- a/src/components/configuration/ProfileValueModal.tsx +++ b/src/components/configuration/ProfileValueModal.tsx @@ -37,7 +37,8 @@ export function ProfileValueModal({ if (!isOpen) onCancel(); }} > - + event.preventDefault()} title={title} showClose onClose={onCancel} className="modal-frost">
{scopeConfig && ( diff --git a/src/components/configuration/ResetBaseConfigDialog.tsx b/src/components/configuration/ResetBaseConfigDialog.tsx index c53dca8f..16f60c31 100644 --- a/src/components/configuration/ResetBaseConfigDialog.tsx +++ b/src/components/configuration/ResetBaseConfigDialog.tsx @@ -19,6 +19,7 @@ export function ResetBaseConfigDialog({ }} > event.preventDefault()} title={localize('com_config_reset_base_title')} showClose onClose={onCancel} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 36507312..2171d5e7 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -32,6 +32,24 @@ const QUICK_LINKS: (t.NavItem & { descKey: string })[] = [ icon: 'question', descKey: 'com_dash_help_desc', }, + { + labelKey: 'com_nav_tools', + path: '/tools', + icon: 'plug', + descKey: 'com_dash_tools_desc', + }, + { + labelKey: 'com_nav_usage', + path: '/usage', + icon: 'bar-chart', + descKey: 'com_dash_usage_desc', + }, + { + labelKey: 'com_nav_services', + path: '/services', + icon: 'server', + descKey: 'com_dash_services_desc', + }, ]; export function DashboardPage() { diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index 9c42eea2..a03644a9 100644 --- a/src/components/grants/AuditLogDetailDrawer.tsx +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -223,6 +223,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -270,6 +271,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -322,6 +324,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -387,6 +390,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index ce2021e5..963255c4 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -24,6 +24,7 @@ import { exportAuditLogServerFn, } from '@/server'; import { + DatePickerCell, EmptyState, LoadingState, Pagination, @@ -41,53 +42,15 @@ const TARGET_TYPE_OPTIONS: readonly PrincipalType[] = [ PrincipalType.GROUP, PrincipalType.ROLE, ] as const; +const TARGET_TYPE_LABEL_KEYS: Record<(typeof TARGET_TYPE_OPTIONS)[number], string> = { + [PrincipalType.USER]: 'com_audit_target_user', + [PrincipalType.GROUP]: 'com_audit_target_group', + [PrincipalType.ROLE]: 'com_audit_target_role', +}; /** Radix `Select.Item` cannot use `value=""` (Radix reserves empty string for * "no selection"). Use a non-empty sentinel and translate to `''` in state. */ const TARGET_TYPE_ALL = '__all__'; -/** - * Wraps a click-ui DatePicker so only the trigger button is tab-focusable. - * click-ui renders both a PopoverTrigger button AND an inner readonly input, - * which produces two stops in the tab order. The input has no exposed `tabIndex` - * prop, so we reach for the DOM node and set it to -1. The class hooks the - * CSS rule that rounds the trigger's focus outline to match the wrapper border. - * - * `resetKey` is the caller's signal that the inner DatePicker has been keyed - * to remount (e.g. the Clear button bumping a nonce): the inner `` is - * replaced and the previous patch is lost, so the effect must re-run and - * re-apply `tabIndex = -1` against the fresh DOM node. - */ -function DatePickerCell({ - children, - resetKey, - inputId, -}: { - children: React.ReactNode; - resetKey?: unknown; - /** - * Stamps the click-ui-rendered `` with an `id` so an external - * `
+ + + ); +} diff --git a/src/components/tools/ImportToolsDialog.tsx b/src/components/tools/ImportToolsDialog.tsx new file mode 100644 index 00000000..dbc7e58e --- /dev/null +++ b/src/components/tools/ImportToolsDialog.tsx @@ -0,0 +1,409 @@ +import { useRef, useState } from 'react'; +import { Button, Dialog, Switch, Tabs } from '@clickhouse/click-ui'; +import type { TerraVoxTool, ImportResult } from '@/server'; +import { importToolsFn } from '@/server'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +type Step = 'input' | 'review' | 'done'; + +/** Live count of manifests in the paste box (0 until it parses). */ +const countParsed = (text: string): number => { + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return parsed.length; + } + if ( + parsed && + typeof parsed === 'object' && + Array.isArray((parsed as { tools?: unknown }).tools) + ) { + return (parsed as { tools: unknown[] }).tools.length; + } + return parsed && typeof parsed === 'object' ? 1 : 0; + } catch { + return 0; + } +}; + +const ACTION_STYLE: Record = { + created: + 'rounded-full bg-(--cui-color-background-success-muted) px-2 py-0.5 text-xs text-(--cui-color-text-success)', + updated: + 'rounded-full bg-(--cui-color-accent-primary-muted) px-2 py-0.5 text-xs text-(--cui-color-text-default)', + skipped: + 'rounded-full bg-(--cui-color-background-muted) px-2 py-0.5 text-xs text-(--cui-color-text-muted)', + failed: + 'rounded-full bg-(--cui-color-background-danger-muted) px-2 py-0.5 text-xs text-(--cui-color-text-danger)', +}; + +/** + * Two-step import: parse manifests (upload .json files or paste JSON) → + * dry-run preview against the gateway → optional overwrite → apply. + * The gateway validates each manifest; per-item outcomes come back in + * `results` for both the preview and the real run. + */ +export function ImportToolsDialog({ + open, + onClose, + onImported, +}: { + open: boolean; + onClose: () => void; + onImported: () => void; +}) { + const localize = useLocalize(); + const fileInputRef = useRef(null); + const [step, setStep] = useState('input'); + const [mode, setMode] = useState('upload'); + const [files, setFiles] = useState<{ name: string; manifest: unknown }[]>([]); + const [pasted, setPasted] = useState(''); + const [parseError, setParseError] = useState(null); + const [overwrite, setOverwrite] = useState(false); + const [results, setResults] = useState(null); + /** Manifests that passed local parsing and the dry run — the apply step + * re-sends exactly this list (possibly with a different overwrite flag). */ + const [pendingTools, setPendingTools] = useState[] | null>(null); + const [busy, setBusy] = useState(false); + const [requestError, setRequestError] = useState(null); + + const reset = () => { + setStep('input'); + setMode('upload'); + setFiles([]); + setPasted(''); + setParseError(null); + setOverwrite(false); + setResults(null); + setPendingTools(null); + setBusy(false); + setRequestError(null); + }; + + const close = () => { + if (busy) { + return; + } + const wasDone = step === 'done'; + reset(); + onClose(); + if (wasDone) { + onImported(); + } + }; + + const readFiles = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) { + return; + } + setParseError(null); + const loaded: { name: string; manifest: unknown }[] = []; + const errors: string[] = []; + for (const file of Array.from(fileList)) { + try { + const manifest = JSON.parse(await file.text()); + loaded.push({ name: file.name, manifest }); + } catch (error) { + errors.push(`${file.name}: ${(error as Error).message}`); + } + } + setFiles((prev) => [...prev, ...loaded]); + if (errors.length > 0) { + setParseError(errors.join('\n')); + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const collectTools = (): { tools: Record[]; error?: string } => { + if (mode === 'upload') { + if (files.length === 0) { + return { tools: [], error: localize('com_tools_import_no_files') }; + } + return { tools: files.map((f) => f.manifest as Record) }; + } + const text = pasted.trim(); + if (!text) { + return { tools: [], error: localize('com_tools_import_no_paste') }; + } + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return { tools: parsed as Record[] }; + } + if (parsed && typeof parsed === 'object') { + /* A catalog export `{tools: [...]}` is accepted too. */ + const inner = (parsed as { tools?: unknown }).tools; + if (Array.isArray(inner)) { + return { tools: inner as Record[] }; + } + return { tools: [parsed as Record] }; + } + return { tools: [], error: localize('com_tools_import_bad_json') }; + } catch (error) { + return { + tools: [], + error: `${localize('com_tools_import_bad_json')}: ${(error as Error).message}`, + }; + } + }; + + const preview = async () => { + const { tools, error } = collectTools(); + if (error || tools.length === 0) { + setParseError(error ?? localize('com_tools_import_no_files')); + return; + } + setParseError(null); + setBusy(true); + setRequestError(null); + try { + const response = await importToolsFn({ + data: { tools, overwrite, dryRun: true }, + }); + setPendingTools(tools); + setResults(response.results); + setStep('review'); + } catch (error) { + setRequestError((error as Error).message); + } finally { + setBusy(false); + } + }; + + const apply = async () => { + if (!pendingTools) { + return; + } + setBusy(true); + setRequestError(null); + try { + const response = await importToolsFn({ + data: { tools: pendingTools, overwrite, dryRun: false }, + }); + setResults(response.results); + setStep('done'); + } catch (error) { + setRequestError((error as Error).message); + } finally { + setBusy(false); + } + }; + + const skippedCount = results?.filter((r) => r.action === 'skipped').length ?? 0; + + return ( + { + if (!isOpen) { + close(); + } + }} + > + event.preventDefault()} + title={localize('com_tools_import_title')} + showClose + onClose={close} + className="modal-frost max-w-2xl!" + > + {step === 'input' && ( +
+

{localize('com_tools_import_hint')}

+ + + {localize('com_tools_import_upload')} + {localize('com_tools_import_paste')} + + + {mode === 'upload' ? ( +
+ + void readFiles(e.target.files)} + /> + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + + {(f.manifest as TerraVoxTool).tool_id ?? '—'} + + +
  • + ))} +
+ )} +
+ ) : ( +