feat: add multi-language (i18n) support to web admin with Chinese and English

- Add react-i18next with browser language auto-detection and localStorage persistence
- Create Zustand locale store with UI language selector in Settings > General
- Add 17 translation namespace files for both English and Chinese (34 JSON files)
- Convert all 37 pages (auth, admin, settings) to use useTranslation hooks
- Convert sidebar and topbar layout components to i18n

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-22 04:56:04 -08:00
parent a7c6aae8c6
commit 660616b08b
77 changed files with 5209 additions and 1677 deletions

View File

@ -25,11 +25,14 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.378.0", "lucide-react": "^0.378.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
"react-i18next": "^16.5.4",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"recharts": "^2.12.0", "recharts": "^2.12.0",
"sonner": "^1.4.0", "sonner": "^1.4.0",
@ -4774,6 +4777,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -5661,6 +5665,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/human-signals": { "node_modules/human-signals": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
@ -5671,6 +5684,47 @@
"node": ">=16.17.0" "node": ">=16.17.0"
} }
}, },
"node_modules/i18next": {
"version": "25.8.13",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7485,6 +7539,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-i18next": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -8869,7 +8950,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"bin": { "bin": {
@ -9218,6 +9299,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -11,52 +11,50 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"next": "^14.2.0", "@hookform/resolvers": "^3.3.0",
"react": "^18.3.0", "@monaco-editor/react": "^4.6.0",
"react-dom": "^18.3.0",
"@reduxjs/toolkit": "^2.2.0",
"react-redux": "^9.1.0",
"zustand": "^4.5.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-table": "^8.17.0",
"tailwindcss": "^3.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.3.0",
"@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dropdown-menu": "^2.0.0", "@radix-ui/react-dropdown-menu": "^2.0.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.0", "@radix-ui/react-tabs": "^1.0.0",
"@radix-ui/react-toast": "^1.1.0", "@radix-ui/react-toast": "^1.1.0",
"@radix-ui/react-tooltip": "^1.0.0", "@radix-ui/react-tooltip": "^1.0.0",
"lucide-react": "^0.378.0", "@reduxjs/toolkit": "^2.2.0",
"@tanstack/react-query": "^5.45.0",
"react-hook-form": "^7.51.0", "@tanstack/react-table": "^8.17.0",
"zod": "^3.23.0",
"@hookform/resolvers": "^3.3.0",
"@monaco-editor/react": "^4.6.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"recharts": "^2.12.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"sonner": "^1.4.0" "i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.378.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.51.0",
"react-i18next": "^16.5.4",
"react-redux": "^9.1.0",
"recharts": "^2.12.0",
"sonner": "^1.4.0",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.0",
"zod": "^3.23.0",
"zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^15.0.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"typescript": "^5.4.0", "autoprefixer": "^10.4.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "^14.2.0", "eslint-config-next": "^14.2.0",
"postcss": "^8.4.0",
"prettier": "^3.2.0", "prettier": "^3.2.0",
"vitest": "^1.6.0", "typescript": "^5.4.0",
"@testing-library/react": "^15.0.0", "vitest": "^1.6.0"
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
} }
} }

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -106,6 +107,9 @@ function HookDialog({
onChange: (field: keyof HookFormData, value: string | number | boolean) => void; onChange: (field: keyof HookFormData, value: string | number | boolean) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -121,7 +125,7 @@ function HookDialog({
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('hooks.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -140,7 +144,7 @@ function HookDialog({
{/* event type */} {/* event type */}
<div> <div>
<label className="block text-sm font-medium mb-1">Event Type</label> <label className="block text-sm font-medium mb-1">{t('hooks.form.eventType')}</label>
<select <select
value={form.event} value={form.event}
onChange={(e) => onChange('event', e.target.value)} onChange={(e) => onChange('event', e.target.value)}
@ -156,7 +160,7 @@ function HookDialog({
{/* tool pattern */} {/* tool pattern */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tool Pattern</label> <label className="block text-sm font-medium mb-1">{t('hooks.form.toolPattern')}</label>
<input <input
type="text" type="text"
value={form.toolPattern} value={form.toolPattern}
@ -172,7 +176,7 @@ function HookDialog({
{/* script content */} {/* script content */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Script <span className="text-destructive">*</span> {t('hooks.form.script')} <span className="text-destructive">*</span>
</label> </label>
<textarea <textarea
value={form.script} value={form.script}
@ -191,7 +195,7 @@ function HookDialog({
{/* timeout */} {/* timeout */}
<div> <div>
<label className="block text-sm font-medium mb-1">Timeout (seconds)</label> <label className="block text-sm font-medium mb-1">{t('hooks.form.timeout')}</label>
<input <input
type="number" type="number"
value={form.timeout} value={form.timeout}
@ -204,7 +208,7 @@ function HookDialog({
{/* enabled toggle */} {/* enabled toggle */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="text-sm font-medium">Enabled</label> <label className="text-sm font-medium">{t('hooks.form.enabled')}</label>
<button <button
type="button" type="button"
role="switch" role="switch"
@ -226,7 +230,7 @@ function HookDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('hooks.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -245,7 +249,7 @@ function HookDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -253,7 +257,7 @@ function HookDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -278,15 +282,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Hook</h2> <h2 className="text-lg font-semibold mb-2">{t('hooks.deleteHook')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{hookName}</strong>? This action cannot be undone. {tc('confirmDelete')} <strong>{hookName}</strong>
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -294,14 +301,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -314,6 +321,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function HooksPage() { export default function HooksPage() {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -447,38 +456,37 @@ export default function HooksPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Hook Scripts</h1> <h1 className="text-2xl font-bold">{t('hooks.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Lifecycle scripts for agent tool execution {t('hooks.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Hook {t('hooks.addHook')}
</button> </button>
</div> </div>
{/* Info banner */} {/* Info banner */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
Hook scripts run at specific lifecycle points during agent execution. PreToolUse hooks {t('hooks.infoBanner')}
can block operations, PostToolUse hooks capture results for auditing.
</p> </p>
</div> </div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load hooks: {(error as Error).message} {t('hooks.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading hooks... {t('hooks.loading')}
</div> </div>
)} )}
@ -489,12 +497,12 @@ export default function HooksPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('hooks.table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Event</th> <th className="text-left px-4 py-3 font-medium">{t('hooks.table.event')}</th>
<th className="text-left px-4 py-3 font-medium">Tool Pattern</th> <th className="text-left px-4 py-3 font-medium">{t('hooks.table.toolPattern')}</th>
<th className="text-left px-4 py-3 font-medium">Script</th> <th className="text-left px-4 py-3 font-medium">{t('hooks.table.script')}</th>
<th className="text-left px-4 py-3 font-medium">Enabled</th> <th className="text-left px-4 py-3 font-medium">{t('hooks.table.enabled')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('hooks.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -504,7 +512,7 @@ export default function HooksPage() {
colSpan={6} colSpan={6}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No hooks configured. Click &quot;Add Hook&quot; to create one. {t('hooks.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -555,13 +563,13 @@ export default function HooksPage() {
onClick={() => openEdit(hook)} onClick={() => openEdit(hook)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(hook)} onClick={() => setDeleteTarget(hook)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -577,7 +585,7 @@ export default function HooksPage() {
{/* Add / Edit dialog */} {/* Add / Edit dialog */}
<HookDialog <HookDialog
open={dialogOpen} open={dialogOpen}
title={editingHook ? 'Edit Hook' : 'Add Hook'} title={editingHook ? t('hooks.editHook') : t('hooks.addHook')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -26,20 +27,14 @@ const DEFAULT_CONFIG: AgentConfig = {
}; };
const AVAILABLE_TOOLS = [ const AVAILABLE_TOOLS = [
{ name: 'Bash', description: 'Execute shell commands' }, 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'NotebookEdit',
{ name: 'Read', description: 'Read file contents' },
{ name: 'Write', description: 'Write / create files' },
{ name: 'Edit', description: 'Edit existing files' },
{ name: 'Glob', description: 'Search files by pattern' },
{ name: 'Grep', description: 'Search file contents' },
{ name: 'WebFetch', description: 'Fetch web content' },
{ name: 'WebSearch', description: 'Search the web' },
{ name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
]; ];
/* ---------- page ---------- */ /* ---------- page ---------- */
export default function AgentConfigPage() { export default function AgentConfigPage() {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/* ---- load existing config ---- */ /* ---- load existing config ---- */
@ -99,7 +94,7 @@ export default function AgentConfigPage() {
} }
function handleSelectAllTools() { function handleSelectAllTools() {
setAllowedTools(AVAILABLE_TOOLS.map((t) => t.name)); setAllowedTools([...AVAILABLE_TOOLS]);
} }
function handleDeselectAllTools() { function handleDeselectAllTools() {
@ -130,7 +125,7 @@ export default function AgentConfigPage() {
return ( return (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span>Loading agent configuration...</span> <span>{t('loading')}</span>
</div> </div>
); );
} }
@ -138,9 +133,9 @@ export default function AgentConfigPage() {
if (isError) { if (isError) {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Agent Configuration</h1> <h1 className="text-2xl font-bold mb-4">{t('title')}</h1>
<p className="text-sm text-destructive mb-4"> <p className="text-sm text-destructive mb-4">
Failed to load configuration. Using defaults. {t('loadError')}
</p> </p>
</div> </div>
); );
@ -152,9 +147,9 @@ export default function AgentConfigPage() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Agent Configuration</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage AI engine settings, system prompts, and allowed tools. {t('subtitle')}
</p> </p>
</div> </div>
</div> </div>
@ -162,9 +157,9 @@ export default function AgentConfigPage() {
<div className="space-y-8"> <div className="space-y-8">
{/* ---- Engine Selection ---- */} {/* ---- Engine Selection ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Engine</h2> <h2 className="text-lg font-semibold mb-1">{t('engine.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Select which Claude engine the agent should use. {t('subtitle')}
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@ -177,9 +172,9 @@ export default function AgentConfigPage() {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<div> <div>
<span className="text-sm font-medium">Claude CLI</span> <span className="text-sm font-medium">{t('engine.claudeCli')}</span>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Run via local Claude CLI binary {t('engine.claudeCliDesc')}
</p> </p>
</div> </div>
</label> </label>
@ -193,9 +188,9 @@ export default function AgentConfigPage() {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<div> <div>
<span className="text-sm font-medium">Claude API</span> <span className="text-sm font-medium">{t('engine.claudeApi')}</span>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Call Anthropic API directly {t('engine.claudeApiDesc')}
</p> </p>
</div> </div>
</label> </label>
@ -204,15 +199,15 @@ export default function AgentConfigPage() {
{/* ---- System Prompt ---- */} {/* ---- System Prompt ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">System Prompt</h2> <h2 className="text-lg font-semibold mb-1">{t('systemPrompt.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
The base system prompt prepended to every agent interaction. {t('systemPrompt.placeholder')}
</p> </p>
<textarea <textarea
value={systemPrompt} value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)} onChange={(e) => setSystemPrompt(e.target.value)}
rows={8} rows={8}
placeholder="You are an IT operations agent..." placeholder={t('systemPrompt.placeholder')}
className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono className="w-full rounded-md border bg-background px-3 py-2 text-sm font-mono
placeholder:text-muted-foreground focus:outline-none focus:ring-2 placeholder:text-muted-foreground focus:outline-none focus:ring-2
focus:ring-ring resize-y" focus:ring-ring resize-y"
@ -224,9 +219,9 @@ export default function AgentConfigPage() {
{/* ---- Max Turns ---- */} {/* ---- Max Turns ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Max Turns</h2> <h2 className="text-lg font-semibold mb-1">{t('maxTurns.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Maximum number of agentic turns the agent may take per task. {t('maxTurns.description')}
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<input <input
@ -254,9 +249,9 @@ export default function AgentConfigPage() {
{/* ---- Max Budget ---- */} {/* ---- Max Budget ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Max Budget</h2> <h2 className="text-lg font-semibold mb-1">{t('maxBudget.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Maximum dollar spend per task execution (USD). {t('maxBudget.description')}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">$</span> <span className="text-sm font-medium text-muted-foreground">$</span>
@ -276,9 +271,9 @@ export default function AgentConfigPage() {
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h2 className="text-lg font-semibold mb-1">Allowed Tools</h2> <h2 className="text-lg font-semibold mb-1">{t('allowedTools.title')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Select which tools the agent is allowed to invoke. {t('allowedTools.title')}
</p> </p>
</div> </div>
<div className="flex gap-2 text-xs"> <div className="flex gap-2 text-xs">
@ -287,39 +282,39 @@ export default function AgentConfigPage() {
onClick={handleSelectAllTools} onClick={handleSelectAllTools}
className="px-2 py-1 rounded border hover:bg-accent transition-colors" className="px-2 py-1 rounded border hover:bg-accent transition-colors"
> >
Select All {t('allowedTools.selectAll')}
</button> </button>
<button <button
type="button" type="button"
onClick={handleDeselectAllTools} onClick={handleDeselectAllTools}
className="px-2 py-1 rounded border hover:bg-accent transition-colors" className="px-2 py-1 rounded border hover:bg-accent transition-colors"
> >
Deselect All {t('allowedTools.deselectAll')}
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{AVAILABLE_TOOLS.map((tool) => ( {AVAILABLE_TOOLS.map((tool) => (
<label <label
key={tool.name} key={tool}
className="flex items-start gap-3 p-3 rounded-md border cursor-pointer className="flex items-start gap-3 p-3 rounded-md border cursor-pointer
hover:bg-accent/50 transition-colors" hover:bg-accent/50 transition-colors"
> >
<input <input
type="checkbox" type="checkbox"
checked={allowedTools.includes(tool.name)} checked={allowedTools.includes(tool)}
onChange={() => handleToolToggle(tool.name)} onChange={() => handleToolToggle(tool)}
className="mt-0.5 h-4 w-4 accent-primary" className="mt-0.5 h-4 w-4 accent-primary"
/> />
<div> <div>
<span className="text-sm font-medium">{tool.name}</span> <span className="text-sm font-medium">{tool}</span>
<p className="text-xs text-muted-foreground">{tool.description}</p> <p className="text-xs text-muted-foreground">{t(`allowedTools.tools.${tool}`)}</p>
</div> </div>
</label> </label>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
{allowedTools.length} of {AVAILABLE_TOOLS.length} tools selected {allowedTools.length} / {AVAILABLE_TOOLS.length}
</p> </p>
</section> </section>
@ -339,7 +334,7 @@ export default function AgentConfigPage() {
) : ( ) : (
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
)} )}
{isSaving ? 'Saving...' : 'Save Configuration'} {isSaving ? tc('saving') : t('saveConfig')}
</button> </button>
<button <button
type="button" type="button"
@ -348,11 +343,11 @@ export default function AgentConfigPage() {
text-sm font-medium hover:bg-accent transition-colors" text-sm font-medium hover:bg-accent transition-colors"
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Reset to Defaults {t('resetDefaults')}
</button> </button>
{saveSuccess && ( {saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400"> <span className="text-sm text-green-600 dark:text-green-400">
Configuration saved successfully. {t('saveSuccess')}
</span> </span>
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -28,21 +29,14 @@ interface UpdateConfigPayload {
} }
const AVAILABLE_TOOLS = [ const AVAILABLE_TOOLS = [
{ name: 'Bash', description: 'Execute shell commands' }, 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebFetch', 'WebSearch', 'NotebookEdit', 'Task',
{ name: 'Read', description: 'Read file contents' },
{ name: 'Write', description: 'Write / create files' },
{ name: 'Edit', description: 'Edit existing files' },
{ name: 'Glob', description: 'Search files by pattern' },
{ name: 'Grep', description: 'Search file contents' },
{ name: 'WebFetch', description: 'Fetch web content' },
{ name: 'WebSearch', description: 'Search the web' },
{ name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
{ name: 'Task', description: 'Launch sub-agent tasks' },
]; ];
/* ---------- page ---------- */ /* ---------- page ---------- */
export default function AgentSdkConfigPage() { export default function AgentSdkConfigPage() {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/* ---- load existing config ---- */ /* ---- load existing config ---- */
@ -153,7 +147,7 @@ export default function AgentSdkConfigPage() {
return ( return (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
<span>Loading SDK configuration...</span> <span>{t('sdk.loading')}</span>
</div> </div>
); );
} }
@ -161,9 +155,9 @@ export default function AgentSdkConfigPage() {
if (isError) { if (isError) {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Agent SDK Configuration</h1> <h1 className="text-2xl font-bold mb-4">{t('sdk.title')}</h1>
<p className="text-sm text-destructive mb-4"> <p className="text-sm text-destructive mb-4">
Failed to load configuration. Using defaults. {t('sdk.loadError')}
</p> </p>
</div> </div>
); );
@ -175,9 +169,9 @@ export default function AgentSdkConfigPage() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Agent SDK Configuration</h1> <h1 className="text-2xl font-bold">{t('sdk.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Configure Claude Agent SDK billing, approval flow, and tool permissions per tenant. {t('sdk.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@ -185,9 +179,9 @@ export default function AgentSdkConfigPage() {
<div className="space-y-8"> <div className="space-y-8">
{/* ---- Billing Mode ---- */} {/* ---- Billing Mode ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Billing Mode</h2> <h2 className="text-lg font-semibold mb-1">{t('sdk.billingMode.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Choose how Claude Agent SDK usage is billed for this tenant. {t('sdk.billingMode.subtitle')}
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@ -200,9 +194,9 @@ export default function AgentSdkConfigPage() {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<div> <div>
<span className="text-sm font-medium">Subscription</span> <span className="text-sm font-medium">{t('sdk.billingMode.subscription')}</span>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Use operator&apos;s Claude login (no API key needed) {t('sdk.billingMode.subscriptionDesc')}
</p> </p>
</div> </div>
</label> </label>
@ -216,9 +210,9 @@ export default function AgentSdkConfigPage() {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<div> <div>
<span className="text-sm font-medium">API Key</span> <span className="text-sm font-medium">{t('sdk.billingMode.apiKey')}</span>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Tenant&apos;s own Anthropic API key (token-based billing) {t('sdk.billingMode.apiKeyDesc')}
</p> </p>
</div> </div>
</label> </label>
@ -228,10 +222,10 @@ export default function AgentSdkConfigPage() {
{billingMode === 'api_key' && ( {billingMode === 'api_key' && (
<div className="mt-4 p-4 rounded-md border bg-muted/30"> <div className="mt-4 p-4 rounded-md border bg-muted/30">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">Anthropic API Key</label> <label className="text-sm font-medium">{t('sdk.apiKey.label')}</label>
{config?.hasApiKey && ( {config?.hasApiKey && (
<span className="text-xs text-green-600 dark:text-green-400"> <span className="text-xs text-green-600 dark:text-green-400">
Key configured {t('sdk.apiKey.configured')}
</span> </span>
)} )}
</div> </div>
@ -241,7 +235,7 @@ export default function AgentSdkConfigPage() {
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder={config?.hasApiKey ? 'Enter new key to replace existing' : 'sk-ant-...'} placeholder={config?.hasApiKey ? t('sdk.apiKey.replacePlaceholder') : t('sdk.apiKey.placeholder')}
className="w-full rounded-md border bg-background px-3 py-2 pr-10 text-sm font-mono className="w-full rounded-md border bg-background px-3 py-2 pr-10 text-sm font-mono
placeholder:text-muted-foreground focus:outline-none focus:ring-2 placeholder:text-muted-foreground focus:outline-none focus:ring-2
focus:ring-ring" focus:ring-ring"
@ -265,12 +259,12 @@ export default function AgentSdkConfigPage() {
disabled:opacity-50 transition-colors" disabled:opacity-50 transition-colors"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Remove {t('sdk.apiKey.remove')}
</button> </button>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
API key is encrypted (AES-256-GCM) before storage. Never stored in plaintext. {t('sdk.apiKey.encryptionNote')}
</p> </p>
</div> </div>
)} )}
@ -278,10 +272,9 @@ export default function AgentSdkConfigPage() {
{/* ---- L2 Approval Timeout ---- */} {/* ---- L2 Approval Timeout ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">L2 Approval Timeout</h2> <h2 className="text-lg font-semibold mb-1">{t('sdk.approvalTimeout.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
For high-risk commands (L2), how long to wait for manual approval before auto-approving. {t('sdk.approvalTimeout.subtitle')}
Set to 0 to disable auto-approve (wait indefinitely).
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<input <input
@ -306,55 +299,54 @@ export default function AgentSdkConfigPage() {
className="w-20 rounded-md border bg-background px-3 py-1.5 text-sm text-center className="w-20 rounded-md border bg-background px-3 py-1.5 text-sm text-center
focus:outline-none focus:ring-2 focus:ring-ring" focus:outline-none focus:ring-2 focus:ring-ring"
/> />
<span className="text-sm text-muted-foreground">sec</span> <span className="text-sm text-muted-foreground">{t('sdk.approvalTimeout.unit')}</span>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{approvalTimeout === 0 {approvalTimeout === 0
? 'Auto-approve disabled — commands will wait for manual approval indefinitely.' ? t('sdk.approvalTimeout.autoApproveDisabled')
: `Commands auto-approved after ${approvalTimeout} seconds without response.`} : t('sdk.approvalTimeout.autoApproveAfter', { seconds: approvalTimeout })}
</p> </p>
</section> </section>
{/* ---- Tool Whitelist / Blacklist ---- */} {/* ---- Tool Whitelist / Blacklist ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">Tool Permissions</h2> <h2 className="text-lg font-semibold mb-1">{t('sdk.toolPermissions.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Override tool access per tenant. Empty whitelist means use RBAC defaults. {t('sdk.toolPermissions.subtitle')}
Blacklisted tools are always denied regardless of role.
</p> </p>
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/50"> <tr className="bg-muted/50">
<th className="text-left px-4 py-2 font-medium">Tool</th> <th className="text-left px-4 py-2 font-medium">{t('sdk.toolPermissions.tool')}</th>
<th className="text-left px-4 py-2 font-medium">Description</th> <th className="text-left px-4 py-2 font-medium">{t('sdk.toolPermissions.description')}</th>
<th className="text-center px-4 py-2 font-medium w-24">Whitelist</th> <th className="text-center px-4 py-2 font-medium w-24">{t('sdk.toolPermissions.whitelist')}</th>
<th className="text-center px-4 py-2 font-medium w-24">Blacklist</th> <th className="text-center px-4 py-2 font-medium w-24">{t('sdk.toolPermissions.blacklist')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{AVAILABLE_TOOLS.map((tool) => ( {AVAILABLE_TOOLS.map((tool) => (
<tr <tr
key={tool.name} key={tool}
className="border-t hover:bg-accent/30 transition-colors" className="border-t hover:bg-accent/30 transition-colors"
> >
<td className="px-4 py-2 font-mono text-xs">{tool.name}</td> <td className="px-4 py-2 font-mono text-xs">{tool}</td>
<td className="px-4 py-2 text-muted-foreground text-xs">{tool.description}</td> <td className="px-4 py-2 text-muted-foreground text-xs">{t(`allowedTools.tools.${tool}`)}</td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
<input <input
type="checkbox" type="checkbox"
checked={toolWhitelist.includes(tool.name)} checked={toolWhitelist.includes(tool)}
onChange={() => handleWhitelistToggle(tool.name)} onChange={() => handleWhitelistToggle(tool)}
className="h-4 w-4 accent-primary cursor-pointer" className="h-4 w-4 accent-primary cursor-pointer"
/> />
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
<input <input
type="checkbox" type="checkbox"
checked={toolBlacklist.includes(tool.name)} checked={toolBlacklist.includes(tool)}
onChange={() => handleBlacklistToggle(tool.name)} onChange={() => handleBlacklistToggle(tool)}
className="h-4 w-4 accent-destructive cursor-pointer" className="h-4 w-4 accent-destructive cursor-pointer"
/> />
</td> </td>
@ -365,34 +357,34 @@ export default function AgentSdkConfigPage() {
</div> </div>
<div className="flex gap-4 mt-3 text-xs text-muted-foreground"> <div className="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>Whitelist: {toolWhitelist.length || 'none (use RBAC defaults)'}</span> <span>{toolWhitelist.length ? t('sdk.toolPermissions.whitelistCount', { count: toolWhitelist.length }) : t('sdk.toolPermissions.whitelistNone')}</span>
<span>Blacklist: {toolBlacklist.length || 'none'}</span> <span>{toolBlacklist.length ? t('sdk.toolPermissions.blacklistCount', { count: toolBlacklist.length }) : t('sdk.toolPermissions.blacklistNone')}</span>
</div> </div>
</section> </section>
{/* ---- RBAC Info ---- */} {/* ---- RBAC Info ---- */}
<section className="bg-card rounded-lg border p-5"> <section className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1">RBAC Tool Access (Reference)</h2> <h2 className="text-lg font-semibold mb-1">{t('sdk.rbac.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Default tool access per role (applied when whitelist is empty). {t('sdk.rbac.subtitle')}
</p> </p>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-medium w-20">Admin</span> <span className="font-medium w-20">{t('sdk.rbac.admin')}</span>
<span className="text-muted-foreground font-mono text-xs"> <span className="text-muted-foreground font-mono text-xs">
All tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task) {t('sdk.rbac.adminTools')}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-medium w-20">Operator</span> <span className="font-medium w-20">{t('sdk.rbac.operator')}</span>
<span className="text-muted-foreground font-mono text-xs"> <span className="text-muted-foreground font-mono text-xs">
Bash, Read, Write, Edit, Glob, Grep {t('sdk.rbac.operatorTools')}
</span> </span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-medium w-20">Viewer</span> <span className="font-medium w-20">{t('sdk.rbac.viewer')}</span>
<span className="text-muted-foreground font-mono text-xs"> <span className="text-muted-foreground font-mono text-xs">
Read, Glob, Grep (read-only) {t('sdk.rbac.viewerTools')}
</span> </span>
</div> </div>
</div> </div>
@ -414,7 +406,7 @@ export default function AgentSdkConfigPage() {
) : ( ) : (
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
)} )}
{isSaving ? 'Saving...' : 'Save Configuration'} {isSaving ? tc('saving') : t('saveConfig')}
</button> </button>
<button <button
type="button" type="button"
@ -423,11 +415,11 @@ export default function AgentSdkConfigPage() {
text-sm font-medium hover:bg-accent transition-colors" text-sm font-medium hover:bg-accent transition-colors"
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Reset to Defaults {t('resetDefaults')}
</button> </button>
{saveSuccess && ( {saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400"> <span className="text-sm text-green-600 dark:text-green-400">
Configuration saved successfully. {t('saveSuccess')}
</span> </span>
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
@ -104,15 +105,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2> <h2 className="text-lg font-semibold mb-2">{t('skills.detail.deleteSkill')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{name}</strong>? This action cannot be undone. {tc('confirmDelete')} <strong>{name}</strong>
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -120,14 +124,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -152,6 +156,8 @@ function formatDate(dateStr: string): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function SkillDetailPage() { export default function SkillDetailPage() {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -286,10 +292,10 @@ export default function SkillDetailPage() {
onClick={() => router.push('/agent-config/skills')} onClick={() => router.push('/agent-config/skills')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Skills &larr; {t('skills.detail.backToSkills')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading skill... {t('skills.detail.loading')}
</div> </div>
</div> </div>
); );
@ -303,10 +309,10 @@ export default function SkillDetailPage() {
onClick={() => router.push('/agent-config/skills')} onClick={() => router.push('/agent-config/skills')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Skills &larr; {t('skills.detail.backToSkills')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load skill: {(error as Error).message} {t('skills.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -320,10 +326,10 @@ export default function SkillDetailPage() {
onClick={() => router.push('/agent-config/skills')} onClick={() => router.push('/agent-config/skills')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Skills &larr; {t('skills.detail.backToSkills')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Skill not found. {t('skills.detail.notFound')}
</div> </div>
</div> </div>
); );
@ -339,20 +345,20 @@ export default function SkillDetailPage() {
onClick={() => router.push('/agent-config/skills')} onClick={() => router.push('/agent-config/skills')}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
> >
&larr; Back &larr; {tc('back')}
</button> </button>
<div className="h-6 w-px bg-border" /> <div className="h-6 w-px bg-border" />
<div> <div>
<h1 className="text-2xl font-bold">{skill.name}</h1> <h1 className="text-2xl font-bold">{skill.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{skill.description || 'No description'} {skill.description || tc('noData')}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{skill.isBuiltIn && ( {skill.isBuiltIn && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
Built-in {t('skills.detail.builtIn')}
</span> </span>
)} )}
<span <span
@ -363,7 +369,7 @@ export default function SkillDetailPage() {
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
)} )}
> >
{skill.enabled ? 'Enabled' : 'Disabled'} {skill.enabled ? t('skills.detail.enabled') : t('skills.detail.disabled')}
</span> </span>
</div> </div>
</div> </div>
@ -381,17 +387,17 @@ export default function SkillDetailPage() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Overview card */} {/* Overview card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Overview</h2> <h2 className="text-lg font-semibold mb-4">{t('skills.detail.overview')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Name {tc('name')}
</label> </label>
<p className="text-sm mt-1 font-mono">{skill.name}</p> <p className="text-sm mt-1 font-mono">{skill.name}</p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Type {tc('type')}
</label> </label>
<p className="mt-1"> <p className="mt-1">
{skill.isBuiltIn ? ( {skill.isBuiltIn ? (
@ -400,22 +406,22 @@ export default function SkillDetailPage() {
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Custom {t('skills.detail.custom')}
</span> </span>
)} )}
</p> </p>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Description {tc('description')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{skill.description || 'No description provided.'} {skill.description || tc('noData')}
</p> </p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Created {tc('created')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{formatDate(skill.createdAt)} {formatDate(skill.createdAt)}
@ -423,7 +429,7 @@ export default function SkillDetailPage() {
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Updated {tc('updated')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{formatDate(skill.updatedAt)} {formatDate(skill.updatedAt)}
@ -435,7 +441,7 @@ export default function SkillDetailPage() {
{/* Prompt Template section */} {/* Prompt Template section */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Prompt Template</h2> <h2 className="text-lg font-semibold">{t('skills.detail.promptTemplate')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditingPrompt ? ( {isEditingPrompt ? (
<> <>
@ -443,14 +449,14 @@ export default function SkillDetailPage() {
onClick={handleCancelEditPrompt} onClick={handleCancelEditPrompt}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={handleSavePrompt} onClick={handleSavePrompt}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{updateMutation.isPending ? 'Saving...' : 'Save'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</> </>
) : ( ) : (
@ -458,7 +464,7 @@ export default function SkillDetailPage() {
onClick={handleStartEditPrompt} onClick={handleStartEditPrompt}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -473,7 +479,7 @@ export default function SkillDetailPage() {
/> />
) : ( ) : (
<div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]"> <div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]">
{skill.promptTemplate || 'No prompt template defined.'} {skill.promptTemplate || t('skills.detail.noPrompt')}
</div> </div>
)} )}
</div> </div>
@ -481,7 +487,7 @@ export default function SkillDetailPage() {
{/* Allowed Tools section */} {/* Allowed Tools section */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Allowed Tools</h2> <h2 className="text-lg font-semibold">{t('skills.detail.allowedTools')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditingTools ? ( {isEditingTools ? (
<> <>
@ -489,14 +495,14 @@ export default function SkillDetailPage() {
onClick={handleCancelEditTools} onClick={handleCancelEditTools}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={handleSaveTools} onClick={handleSaveTools}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{updateMutation.isPending ? 'Saving...' : 'Save'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</> </>
) : ( ) : (
@ -504,7 +510,7 @@ export default function SkillDetailPage() {
onClick={handleStartEditTools} onClick={handleStartEditTools}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -531,7 +537,7 @@ export default function SkillDetailPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{skill.allowedTools.length === 0 ? ( {skill.allowedTools.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tools configured. {t('skills.detail.noTools')}
</p> </p>
) : ( ) : (
skill.allowedTools.map((tool) => ( skill.allowedTools.map((tool) => (
@ -552,12 +558,12 @@ export default function SkillDetailPage() {
<div className="lg:col-span-1 space-y-6"> <div className="lg:col-span-1 space-y-6">
{/* Configuration card */} {/* Configuration card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Configuration</h2> <h2 className="text-lg font-semibold mb-4">{t('skills.detail.configuration')}</h2>
<div className="space-y-5"> <div className="space-y-5">
{/* Type */} {/* Type */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Type {tc('type')}
</label> </label>
<p className="mt-1.5"> <p className="mt-1.5">
{skill.isBuiltIn ? ( {skill.isBuiltIn ? (
@ -566,7 +572,7 @@ export default function SkillDetailPage() {
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Custom {t('skills.detail.custom')}
</span> </span>
)} )}
</p> </p>
@ -575,20 +581,20 @@ export default function SkillDetailPage() {
{/* Enabled toggle */} {/* Enabled toggle */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
Status {tc('status')}
</label> </label>
<ToggleSwitch <ToggleSwitch
checked={skill.enabled} checked={skill.enabled}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
onChange={handleToggleEnabled} onChange={handleToggleEnabled}
label={skill.enabled ? 'Enabled' : 'Disabled'} label={skill.enabled ? tc('enabled') : tc('disabled')}
/> />
</div> </div>
{/* Allowed Tools count */} {/* Allowed Tools count */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Allowed Tools {t('skills.detail.allowedTools')}
</label> </label>
<p className="text-sm mt-1 font-medium"> <p className="text-sm mt-1 font-medium">
{skill.allowedTools.length} tool{skill.allowedTools.length !== 1 ? 's' : ''} configured {skill.allowedTools.length} tool{skill.allowedTools.length !== 1 ? 's' : ''} configured
@ -599,7 +605,7 @@ export default function SkillDetailPage() {
{/* Quick Actions card */} {/* Quick Actions card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('skills.detail.quickActions')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{/* Duplicate */} {/* Duplicate */}
<button <button
@ -607,7 +613,7 @@ export default function SkillDetailPage() {
disabled={duplicateMutation.isPending} disabled={duplicateMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50" className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50"
> >
{duplicateMutation.isPending ? 'Duplicating...' : 'Duplicate Skill'} {duplicateMutation.isPending ? tc('loading') : t('skills.detail.duplicateSkill')}
</button> </button>
{/* Delete (only for non-built-in) */} {/* Delete (only for non-built-in) */}
@ -617,13 +623,13 @@ export default function SkillDetailPage() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50" className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50"
> >
Delete Skill {t('skills.detail.deleteSkill')}
</button> </button>
)} )}
{skill.isBuiltIn && ( {skill.isBuiltIn && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Built-in skills cannot be deleted. {t('skills.detail.builtIn')}
</p> </p>
)} )}
</div> </div>
@ -631,22 +637,22 @@ export default function SkillDetailPage() {
{/* Metadata */} {/* Metadata */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Metadata</h2> <h2 className="text-lg font-semibold mb-4">{t('skills.detail.metadata')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Name</span> <span className="text-sm text-muted-foreground">{tc('name')}</span>
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{skill.name} {skill.name}
</code> </code>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span> <span className="text-sm text-muted-foreground">{tc('created')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(skill.createdAt)} {formatDate(skill.createdAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Updated</span> <span className="text-sm text-muted-foreground">{tc('updated')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(skill.updatedAt)} {formatDate(skill.updatedAt)}
</span> </span>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -41,13 +42,8 @@ interface SkillsResponse {
const SKILL_QUERY_KEY = ['skills'] as const; const SKILL_QUERY_KEY = ['skills'] as const;
const CATEGORIES: { label: string; value: Skill['category'] }[] = [ const CATEGORY_VALUES: Skill['category'][] = [
{ label: 'Inspection', value: 'inspection' }, 'inspection', 'deployment', 'maintenance', 'security', 'monitoring', 'custom',
{ label: 'Deployment', value: 'deployment' },
{ label: 'Maintenance', value: 'maintenance' },
{ label: 'Security', value: 'security' },
{ label: 'Monitoring', value: 'monitoring' },
{ label: 'Custom', value: 'custom' },
]; ];
const CATEGORY_STYLES: Record<Skill['category'], string> = { const CATEGORY_STYLES: Record<Skill['category'], string> = {
@ -73,6 +69,7 @@ const EMPTY_FORM: SkillFormData = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function CategoryBadge({ category }: { category: Skill['category'] }) { function CategoryBadge({ category }: { category: Skill['category'] }) {
const { t } = useTranslation('agent-config');
return ( return (
<span <span
className={cn( className={cn(
@ -80,7 +77,7 @@ function CategoryBadge({ category }: { category: Skill['category'] }) {
CATEGORY_STYLES[category], CATEGORY_STYLES[category],
)} )}
> >
{category} {t(`skills.categories.${category}`)}
</span> </span>
); );
} }
@ -90,6 +87,7 @@ function CategoryBadge({ category }: { category: Skill['category'] }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function EnabledBadge({ enabled }: { enabled: boolean }) { function EnabledBadge({ enabled }: { enabled: boolean }) {
const { t: tc } = useTranslation('common');
return ( return (
<span <span
className={cn( className={cn(
@ -99,7 +97,7 @@ function EnabledBadge({ enabled }: { enabled: boolean }) {
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400', : 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
)} )}
> >
{enabled ? 'Enabled' : 'Disabled'} {enabled ? tc('enabled') : tc('disabled')}
</span> </span>
); );
} }
@ -127,6 +125,9 @@ function SkillDialog({
onChange: (field: keyof SkillFormData, value: string | boolean) => void; onChange: (field: keyof SkillFormData, value: string | boolean) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -142,7 +143,7 @@ function SkillDialog({
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('skills.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -161,7 +162,7 @@ function SkillDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('skills.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -173,15 +174,15 @@ function SkillDialog({
{/* category */} {/* category */}
<div> <div>
<label className="block text-sm font-medium mb-1">Category</label> <label className="block text-sm font-medium mb-1">{t('skills.form.category')}</label>
<select <select
value={form.category} value={form.category}
onChange={(e) => onChange('category', e.target.value)} onChange={(e) => onChange('category', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
> >
{CATEGORIES.map((cat) => ( {CATEGORY_VALUES.map((cat) => (
<option key={cat.value} value={cat.value}> <option key={cat} value={cat}>
{cat.label} {t(`skills.categories.${cat}`)}
</option> </option>
))} ))}
</select> </select>
@ -189,7 +190,7 @@ function SkillDialog({
{/* script */} {/* script */}
<div> <div>
<label className="block text-sm font-medium mb-1">Script</label> <label className="block text-sm font-medium mb-1">{t('skills.form.script')}</label>
<textarea <textarea
value={form.script} value={form.script}
onChange={(e) => onChange('script', e.target.value)} onChange={(e) => onChange('script', e.target.value)}
@ -200,7 +201,7 @@ function SkillDialog({
{/* tags */} {/* tags */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tags</label> <label className="block text-sm font-medium mb-1">{t('skills.form.tags')}</label>
<input <input
type="text" type="text"
value={form.tags} value={form.tags}
@ -221,7 +222,7 @@ function SkillDialog({
/> />
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary" /> <div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-ring rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary" />
</label> </label>
<span className="text-sm font-medium">Enabled</span> <span className="text-sm font-medium">{t('skills.form.enabled')}</span>
</div> </div>
</div> </div>
@ -233,7 +234,7 @@ function SkillDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -241,7 +242,7 @@ function SkillDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -266,15 +267,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Skill</h2> <h2 className="text-lg font-semibold mb-2">{t('skills.deleteSkill')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{skillName}</strong>? This action cannot be undone. {tc('confirmDelete')} <strong>{skillName}</strong>
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -282,14 +286,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -302,6 +306,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function SkillsPage() { export default function SkillsPage() {
const { t } = useTranslation('agent-config');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -424,30 +430,30 @@ export default function SkillsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Skills</h1> <h1 className="text-2xl font-bold">{t('skills.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage Claude Code skills for the AI agent {t('skills.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Skill {t('skills.addSkill')}
</button> </button>
</div> </div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load skills: {(error as Error).message} {t('skills.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading skills... {t('skills.loading')}
</div> </div>
)} )}
@ -455,13 +461,13 @@ export default function SkillsPage() {
{!isLoading && !error && skills.length === 0 && ( {!isLoading && !error && skills.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
No skills configured yet. Add your first skill to get started. {t('skills.empty')}
</p> </p>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
> >
Add Skill {t('skills.addSkill')}
</button> </button>
</div> </div>
)} )}
@ -482,7 +488,7 @@ export default function SkillsPage() {
{/* Description */} {/* Description */}
<p className="text-xs text-muted-foreground line-clamp-2 mt-1"> <p className="text-xs text-muted-foreground line-clamp-2 mt-1">
{skill.description || 'No description'} {skill.description || tc('noData')}
</p> </p>
{/* Category badge */} {/* Category badge */}
@ -511,13 +517,13 @@ export default function SkillsPage() {
onClick={() => openEdit(skill)} onClick={() => openEdit(skill)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(skill)} onClick={() => setDeleteTarget(skill)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -529,7 +535,7 @@ export default function SkillsPage() {
{/* Add / Edit dialog */} {/* Add / Edit dialog */}
<SkillDialog <SkillDialog
open={dialogOpen} open={dialogOpen}
title={editingSkill ? 'Edit Skill' : 'Add Skill'} title={editingSkill ? t('skills.editSkill') : t('skills.addSkill')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -45,6 +46,8 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// ── Main Component ────────────────────────────────────────────────────────── // ── Main Component ──────────────────────────────────────────────────────────
export default function AuditLogsPage() { export default function AuditLogsPage() {
const { t } = useTranslation('audit');
const { t: tc } = useTranslation('common');
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: '',
@ -99,7 +102,7 @@ export default function AuditLogsPage() {
const exportCsv = useCallback(() => { const exportCsv = useCallback(() => {
if (logs.length === 0) return; if (logs.length === 0) return;
const headers = ['Timestamp', 'Action Type', 'Actor Type', 'Actor ID', 'Resource Type', 'Resource ID', 'Description']; const headers = [t('logs.table.timestamp'), t('logs.table.action'), t('logs.table.actorType'), t('logs.table.actorId'), t('logs.table.resourceType'), t('logs.table.resourceId'), t('logs.table.description')];
const rows = logs.map((log) => [ const rows = logs.map((log) => [
log.timestamp, log.timestamp,
log.actionType, log.actionType,
@ -120,7 +123,7 @@ export default function AuditLogsPage() {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [logs]); }, [logs, t]);
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
@ -128,9 +131,9 @@ export default function AuditLogsPage() {
<div> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Audit Logs</h1> <h1 className="text-2xl font-bold">{t('logs.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
View immutable audit trail of all operations and configuration changes. {t('logs.subtitle')}
</p> </p>
</div> </div>
<button <button
@ -138,14 +141,14 @@ export default function AuditLogsPage() {
disabled={logs.length === 0} disabled={logs.length === 0}
className="px-4 py-2 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
> >
Export CSV {t('logs.exportCsv')}
</button> </button>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-4 p-4 bg-card border rounded-lg"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 mb-4 p-4 bg-card border rounded-lg">
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Date From</label> <label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.dateFrom')}</label>
<input <input
type="date" type="date"
value={filters.dateFrom} value={filters.dateFrom}
@ -154,7 +157,7 @@ export default function AuditLogsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Date To</label> <label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.dateTo')}</label>
<input <input
type="date" type="date"
value={filters.dateTo} value={filters.dateTo}
@ -163,46 +166,46 @@ export default function AuditLogsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Action Type</label> <label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.actionType')}</label>
<select <select
value={filters.actionType} value={filters.actionType}
onChange={(e) => updateFilter('actionType', e.target.value)} onChange={(e) => updateFilter('actionType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm" className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
> >
{ACTION_TYPES.map((t) => ( {ACTION_TYPES.map((at) => (
<option key={t} value={t}>{t || 'All'}</option> <option key={at} value={at}>{at || tc('all')}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Actor Type</label> <label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.actorType')}</label>
<select <select
value={filters.actorType} value={filters.actorType}
onChange={(e) => updateFilter('actorType', e.target.value)} onChange={(e) => updateFilter('actorType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm" className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
> >
{ACTOR_TYPES.map((t) => ( {ACTOR_TYPES.map((at) => (
<option key={t} value={t}>{t || 'All'}</option> <option key={at} value={at}>{at || tc('all')}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Resource Type</label> <label className="block text-xs text-muted-foreground mb-1">{t('logs.filters.resourceType')}</label>
<select <select
value={filters.resourceType} value={filters.resourceType}
onChange={(e) => updateFilter('resourceType', e.target.value)} onChange={(e) => updateFilter('resourceType', e.target.value)}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm" className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
> >
{RESOURCE_TYPES.map((t) => ( {RESOURCE_TYPES.map((rt) => (
<option key={t} value={t}>{t || 'All'}</option> <option key={rt} value={rt}>{rt || tc('all')}</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Loading / Error */} {/* Loading / Error */}
{isLoading && <p className="text-muted-foreground py-4">Loading audit logs...</p>} {isLoading && <p className="text-muted-foreground py-4">{t('logs.loading')}</p>}
{error && <p className="text-red-500 py-4">Error loading logs: {(error as Error).message}</p>} {error && <p className="text-red-500 py-4">{t('logs.loadError')} {(error as Error).message}</p>}
{/* Table */} {/* Table */}
{!isLoading && !error && ( {!isLoading && !error && (
@ -212,13 +215,13 @@ export default function AuditLogsPage() {
<thead> <thead>
<tr className="border-b bg-muted/50 text-left"> <tr className="border-b bg-muted/50 text-left">
<th className="py-2 px-3 font-medium w-8"></th> <th className="py-2 px-3 font-medium w-8"></th>
<th className="py-2 px-3 font-medium">Timestamp</th> <th className="py-2 px-3 font-medium">{t('logs.table.timestamp')}</th>
<th className="py-2 px-3 font-medium">Action</th> <th className="py-2 px-3 font-medium">{t('logs.table.action')}</th>
<th className="py-2 px-3 font-medium">Actor Type</th> <th className="py-2 px-3 font-medium">{t('logs.table.actorType')}</th>
<th className="py-2 px-3 font-medium">Actor ID</th> <th className="py-2 px-3 font-medium">{t('logs.table.actorId')}</th>
<th className="py-2 px-3 font-medium">Resource Type</th> <th className="py-2 px-3 font-medium">{t('logs.table.resourceType')}</th>
<th className="py-2 px-3 font-medium">Resource ID</th> <th className="py-2 px-3 font-medium">{t('logs.table.resourceId')}</th>
<th className="py-2 px-3 font-medium">Description</th> <th className="py-2 px-3 font-medium">{t('logs.table.description')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -233,7 +236,7 @@ export default function AuditLogsPage() {
{logs.length === 0 && ( {logs.length === 0 && (
<tr> <tr>
<td colSpan={8} className="py-8 text-center text-muted-foreground"> <td colSpan={8} className="py-8 text-center text-muted-foreground">
No audit logs found for the current filters. {t('logs.empty')}
</td> </td>
</tr> </tr>
)} )}
@ -244,7 +247,7 @@ export default function AuditLogsPage() {
{/* Pagination */} {/* Pagination */}
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page:</span> <span>{t('logs.pagination.rowsPerPage')}:</span>
<select <select
value={pageSize} value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))} onChange={(e) => handlePageSizeChange(Number(e.target.value))}
@ -257,7 +260,7 @@ export default function AuditLogsPage() {
<span className="ml-2"> <span className="ml-2">
{total > 0 {total > 0
? `${(page - 1) * pageSize + 1}--${Math.min(page * pageSize, total)} of ${total}` ? `${(page - 1) * pageSize + 1}--${Math.min(page * pageSize, total)} of ${total}`
: '0 results'} : tc('noResults')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -266,17 +269,17 @@ export default function AuditLogsPage() {
disabled={page <= 1} disabled={page <= 1}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
> >
Previous {t('logs.pagination.previous')}
</button> </button>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Page {page} of {totalPages} {t('logs.pagination.pageOf', { current: page, total: totalPages })}
</span> </span>
<button <button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
> >
Next {t('logs.pagination.next')}
</button> </button>
</div> </div>
</div> </div>
@ -307,6 +310,7 @@ function LogRow({
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const { t } = useTranslation('audit');
const formattedTime = useMemo(() => { const formattedTime = useMemo(() => {
try { try {
return new Date(log.timestamp).toLocaleString(); return new Date(log.timestamp).toLocaleString();
@ -341,7 +345,7 @@ function LogRow({
{isExpanded && ( {isExpanded && (
<tr className="border-b"> <tr className="border-b">
<td colSpan={8} className="p-4 bg-muted/30"> <td colSpan={8} className="p-4 bg-muted/30">
<p className="text-xs font-medium text-muted-foreground mb-2">Full Detail</p> <p className="text-xs font-medium text-muted-foreground mb-2">{t('logs.fullDetail')}</p>
<pre className="text-xs bg-background border rounded-md p-3 overflow-x-auto max-h-64 overflow-y-auto"> <pre className="text-xs bg-background border rounded-md p-3 overflow-x-auto max-h-64 overflow-y-auto">
<code>{JSON.stringify(log.detail ?? log, null, 2)}</code> <code>{JSON.stringify(log.detail ?? log, null, 2)}</code>
</pre> </pre>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -60,11 +61,11 @@ interface Filters {
// -- Constants --------------------------------------------------------------- // -- Constants ---------------------------------------------------------------
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ label: 'All', value: '' }, { labelKey: 'replay.statuses.all', value: '' },
{ label: 'Completed', value: 'completed' }, { labelKey: 'replay.statuses.completed', value: 'completed' },
{ label: 'Failed', value: 'failed' }, { labelKey: 'replay.statuses.failed', value: 'failed' },
{ label: 'Cancelled', value: 'cancelled' }, { labelKey: 'replay.statuses.cancelled', value: 'cancelled' },
{ label: 'Running', value: 'running' }, { labelKey: 'replay.statuses.running', value: 'running' },
]; ];
const STATUS_BADGE_STYLES: Record<string, string> = { const STATUS_BADGE_STYLES: Record<string, string> = {
@ -85,15 +86,15 @@ const EVENT_TYPE_STYLES: Record<string, string> = {
session_completed: 'bg-green-100 text-green-800', session_completed: 'bg-green-100 text-green-800',
}; };
const EVENT_TYPE_LABELS: Record<string, string> = { const EVENT_TYPE_LABEL_KEYS: Record<string, string> = {
command_executed: 'Command Executed', command_executed: 'replay.events.types.commandExecuted',
output_received: 'Output Received', output_received: 'replay.events.types.outputReceived',
approval_requested: 'Approval Requested', approval_requested: 'replay.events.types.approvalRequested',
approval_granted: 'Approval Granted', approval_granted: 'replay.events.types.approvalGranted',
approval_denied: 'Approval Denied', approval_denied: 'replay.events.types.approvalDenied',
error: 'Error', error: 'replay.events.types.error',
session_started: 'Session Started', session_started: 'replay.events.types.sessionStarted',
session_completed: 'Session Completed', session_completed: 'replay.events.types.sessionCompleted',
}; };
const RISK_LEVEL_STYLES: Record<string, string> = { const RISK_LEVEL_STYLES: Record<string, string> = {
@ -146,6 +147,7 @@ function truncateId(id: string, maxLen = 12): string {
// -- Main Component ---------------------------------------------------------- // -- Main Component ----------------------------------------------------------
export default function SessionReplayPage() { export default function SessionReplayPage() {
const { t } = useTranslation('audit');
const [filters, setFilters] = useState<Filters>({ const [filters, setFilters] = useState<Filters>({
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: '',
@ -315,16 +317,16 @@ export default function SessionReplayPage() {
<div> <div>
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold">Session Replay</h1> <h1 className="text-2xl font-bold">{t('replay.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Review agent session execution history {t('replay.subtitle')}
</p> </p>
</div> </div>
{/* Filters */} {/* Filters */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4 p-4 bg-card border rounded-lg"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mb-4 p-4 bg-card border rounded-lg">
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Date From</label> <label className="block text-xs text-muted-foreground mb-1">{t('replay.filters.dateFrom')}</label>
<input <input
type="date" type="date"
value={filters.dateFrom} value={filters.dateFrom}
@ -333,7 +335,7 @@ export default function SessionReplayPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Date To</label> <label className="block text-xs text-muted-foreground mb-1">{t('replay.filters.dateTo')}</label>
<input <input
type="date" type="date"
value={filters.dateTo} value={filters.dateTo}
@ -342,7 +344,7 @@ export default function SessionReplayPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Status</label> <label className="block text-xs text-muted-foreground mb-1">{t('replay.filters.status')}</label>
<select <select
value={filters.status} value={filters.status}
onChange={(e) => updateFilter('status', e.target.value)} onChange={(e) => updateFilter('status', e.target.value)}
@ -350,18 +352,18 @@ export default function SessionReplayPage() {
> >
{STATUS_OPTIONS.map((opt) => ( {STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey)}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Search</label> <label className="block text-xs text-muted-foreground mb-1">{t('replay.filters.search')}</label>
<input <input
type="text" type="text"
value={filters.search} value={filters.search}
onChange={(e) => updateFilter('search', e.target.value)} onChange={(e) => updateFilter('search', e.target.value)}
placeholder="Session ID or task description..." placeholder={t('replay.filters.searchPlaceholder')}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm" className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
/> />
</div> </div>
@ -369,11 +371,11 @@ export default function SessionReplayPage() {
{/* Loading / Error for sessions */} {/* Loading / Error for sessions */}
{sessionsLoading && ( {sessionsLoading && (
<p className="text-muted-foreground py-4">Loading sessions...</p> <p className="text-muted-foreground py-4">{t('replay.loading')}</p>
)} )}
{sessionsError && ( {sessionsError && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load sessions: {(sessionsError as Error).message} {t('replay.loadError')} {(sessionsError as Error).message}
</div> </div>
)} )}
@ -383,12 +385,12 @@ export default function SessionReplayPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50 text-left"> <tr className="border-b bg-muted/50 text-left">
<th className="py-2 px-3 font-medium">Session ID</th> <th className="py-2 px-3 font-medium">{t('replay.table.sessionId')}</th>
<th className="py-2 px-3 font-medium">Task Description</th> <th className="py-2 px-3 font-medium">{t('replay.table.taskDescription')}</th>
<th className="py-2 px-3 font-medium">Status</th> <th className="py-2 px-3 font-medium">{t('replay.table.status')}</th>
<th className="py-2 px-3 font-medium">Duration</th> <th className="py-2 px-3 font-medium">{t('replay.table.duration')}</th>
<th className="py-2 px-3 font-medium">Commands</th> <th className="py-2 px-3 font-medium">{t('replay.table.commands')}</th>
<th className="py-2 px-3 font-medium">Started At</th> <th className="py-2 px-3 font-medium">{t('replay.table.startedAt')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -438,7 +440,7 @@ export default function SessionReplayPage() {
colSpan={6} colSpan={6}
className="py-8 text-center text-muted-foreground" className="py-8 text-center text-muted-foreground"
> >
No sessions found for the current filters. {t('replay.empty')}
</td> </td>
</tr> </tr>
)} )}
@ -446,7 +448,7 @@ export default function SessionReplayPage() {
</table> </table>
{total > 0 && ( {total > 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground border-t bg-muted/30"> <div className="px-3 py-2 text-xs text-muted-foreground border-t bg-muted/30">
Showing {sessions.length} of {total} sessions {t('replay.showing', { count: sessions.length, total })}
</div> </div>
)} )}
</div> </div>
@ -460,7 +462,7 @@ export default function SessionReplayPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<div className="flex items-center gap-3 mb-1"> <div className="flex items-center gap-3 mb-1">
<h2 className="text-lg font-semibold">Session Replay</h2> <h2 className="text-lg font-semibold">{t('replay.panel.title')}</h2>
<span <span
className={cn( className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize', 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
@ -473,20 +475,20 @@ export default function SessionReplayPage() {
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span> <span>
<span className="font-medium">ID:</span>{' '} <span className="font-medium">{t('replay.panel.id')}</span>{' '}
<span className="font-mono">{selectedSession.id}</span> <span className="font-mono">{selectedSession.id}</span>
</span> </span>
<span> <span>
<span className="font-medium">Duration:</span>{' '} <span className="font-medium">{t('replay.panel.duration')}</span>{' '}
{formatDuration(selectedSession.durationMs)} {formatDuration(selectedSession.durationMs)}
</span> </span>
<span> <span>
<span className="font-medium">Commands:</span>{' '} <span className="font-medium">{t('replay.panel.commands')}</span>{' '}
{selectedSession.commandCount} {selectedSession.commandCount}
</span> </span>
{selectedSession.serverTargets.length > 0 && ( {selectedSession.serverTargets.length > 0 && (
<span> <span>
<span className="font-medium">Servers:</span>{' '} <span className="font-medium">{t('replay.panel.servers')}</span>{' '}
{selectedSession.serverTargets.join(', ')} {selectedSession.serverTargets.join(', ')}
</span> </span>
)} )}
@ -496,7 +498,7 @@ export default function SessionReplayPage() {
onClick={() => setSelectedSessionId(null)} onClick={() => setSelectedSessionId(null)}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent transition-colors self-start" className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent transition-colors self-start"
> >
Close {t('replay.panel.close')}
</button> </button>
</div> </div>
@ -513,14 +515,14 @@ export default function SessionReplayPage() {
disabled={events.length === 0} disabled={events.length === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Play {t('replay.playback.play')}
</button> </button>
) : ( ) : (
<button <button
onClick={handlePause} onClick={handlePause}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
> >
Pause {t('replay.playback.pause')}
</button> </button>
)} )}
<button <button
@ -528,19 +530,19 @@ export default function SessionReplayPage() {
disabled={events.length === 0} disabled={events.length === 0}
className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Reset {t('replay.playback.reset')}
</button> </button>
<button <button
onClick={handleShowAll} onClick={handleShowAll}
disabled={events.length === 0} disabled={events.length === 0}
className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-3 py-1.5 text-xs rounded-md border hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
Show All {t('replay.playback.showAll')}
</button> </button>
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-muted-foreground">Speed:</span> <span className="text-xs text-muted-foreground">{t('replay.playback.speed')}</span>
<div className="flex gap-1"> <div className="flex gap-1">
{PLAYBACK_SPEEDS.map((speed) => ( {PLAYBACK_SPEEDS.map((speed) => (
<button <button
@ -559,8 +561,8 @@ export default function SessionReplayPage() {
</div> </div>
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
{visibleEventIndex >= 0 {visibleEventIndex >= 0
? `${Math.min(visibleEventIndex + 1, events.length)} / ${events.length} events` ? t('replay.playback.eventsProgress', { current: Math.min(visibleEventIndex + 1, events.length), total: events.length })
: `${events.length} events`} : `${events.length} ${t('replay.playback.events')}`}
</span> </span>
</div> </div>
</div> </div>
@ -568,12 +570,12 @@ export default function SessionReplayPage() {
{/* Events Loading / Error */} {/* Events Loading / Error */}
{eventsLoading && ( {eventsLoading && (
<div className="p-4 text-muted-foreground text-sm"> <div className="p-4 text-muted-foreground text-sm">
Loading session events... {t('replay.events.loading')}
</div> </div>
)} )}
{eventsError && ( {eventsError && (
<div className="p-4 text-red-500 text-sm"> <div className="p-4 text-red-500 text-sm">
Error loading events: {(eventsError as Error).message} {t('replay.events.loadError')} {(eventsError as Error).message}
</div> </div>
)} )}
@ -582,13 +584,13 @@ export default function SessionReplayPage() {
<div className="p-4 max-h-[600px] overflow-y-auto"> <div className="p-4 max-h-[600px] overflow-y-auto">
{visibleEvents.length === 0 && events.length === 0 && ( {visibleEvents.length === 0 && events.length === 0 && (
<p className="text-center text-muted-foreground py-8"> <p className="text-center text-muted-foreground py-8">
No events recorded for this session. {t('replay.events.empty')}
</p> </p>
)} )}
{visibleEvents.length === 0 && events.length > 0 && ( {visibleEvents.length === 0 && events.length > 0 && (
<p className="text-center text-muted-foreground py-8"> <p className="text-center text-muted-foreground py-8">
Press Play to begin the session replay. {t('replay.events.pressPlay')}
</p> </p>
)} )}
@ -625,6 +627,7 @@ function EventCard({
isOutputExpanded: boolean; isOutputExpanded: boolean;
onToggleOutput: () => void; onToggleOutput: () => void;
}) { }) {
const { t } = useTranslation('audit');
const relativeTime = useMemo( const relativeTime = useMemo(
() => formatRelativeTime(event.timestamp, sessionStartedAt), () => formatRelativeTime(event.timestamp, sessionStartedAt),
[event.timestamp, sessionStartedAt], [event.timestamp, sessionStartedAt],
@ -643,7 +646,7 @@ function EventCard({
EVENT_TYPE_STYLES[event.type] ?? 'bg-muted text-muted-foreground', EVENT_TYPE_STYLES[event.type] ?? 'bg-muted text-muted-foreground',
)} )}
> >
{EVENT_TYPE_LABELS[event.type] ?? event.type} {EVENT_TYPE_LABEL_KEYS[event.type] ? t(EVENT_TYPE_LABEL_KEYS[event.type]) : event.type}
</span> </span>
{event.data.riskLevel && ( {event.data.riskLevel && (
<span <span
@ -697,7 +700,7 @@ function EventCard({
onClick={onToggleOutput} onClick={onToggleOutput}
className="text-xs text-muted-foreground hover:text-foreground mt-1 underline transition-colors" className="text-xs text-muted-foreground hover:text-foreground mt-1 underline transition-colors"
> >
{isOutputExpanded ? 'Collapse' : 'Expand full output'} {isOutputExpanded ? t('replay.events.collapse') : t('replay.events.expandFullOutput')}
</button> </button>
)} )}
</div> </div>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -50,70 +51,77 @@ interface PaginatedResponse<T> {
// ── Channel config field definitions ──────────────────────────────────────── // ── Channel config field definitions ────────────────────────────────────────
const CHANNEL_CONFIG_FIELDS: Record<ChannelType, { key: string; label: string; type?: string }[]> = { const CHANNEL_CONFIG_FIELDS: Record<ChannelType, { key: string; labelKey: string; type?: string }[]> = {
email: [ email: [
{ key: 'smtpHost', label: 'SMTP Host' }, { key: 'smtpHost', labelKey: 'channels.fields.smtpHost' },
{ key: 'smtpPort', label: 'SMTP Port' }, { key: 'smtpPort', labelKey: 'channels.fields.smtpPort' },
{ key: 'smtpUser', label: 'SMTP Username' }, { key: 'smtpUser', labelKey: 'channels.fields.username' },
{ key: 'smtpPass', label: 'SMTP Password', type: 'password' }, { key: 'smtpPass', labelKey: 'channels.fields.password', type: 'password' },
{ key: 'fromAddress', label: 'From Address' }, { key: 'fromAddress', labelKey: 'channels.fields.fromAddress' },
], ],
telegram: [ telegram: [
{ key: 'botToken', label: 'Bot Token', type: 'password' }, { key: 'botToken', labelKey: 'channels.fields.botToken', type: 'password' },
{ key: 'chatId', label: 'Chat ID' }, { key: 'chatId', labelKey: 'channels.fields.chatId' },
], ],
sms: [ sms: [
{ key: 'provider', label: 'Provider' }, { key: 'provider', labelKey: 'channels.fields.provider' },
{ key: 'apiKey', label: 'API Key', type: 'password' }, { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
{ key: 'fromNumber', label: 'From Number' }, { key: 'fromNumber', labelKey: 'channels.fields.fromNumber' },
], ],
push: [ push: [
{ key: 'provider', label: 'Provider' }, { key: 'provider', labelKey: 'channels.fields.provider' },
{ key: 'apiKey', label: 'API Key', type: 'password' }, { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
{ key: 'appId', label: 'App ID' }, { key: 'appId', labelKey: 'channels.fields.appId' },
], ],
voice_call: [ voice_call: [
{ key: 'provider', label: 'Provider' }, { key: 'provider', labelKey: 'channels.fields.provider' },
{ key: 'apiKey', label: 'API Key', type: 'password' }, { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
{ key: 'fromNumber', label: 'From Number' }, { key: 'fromNumber', labelKey: 'channels.fields.fromNumber' },
], ],
wechat_work: [ wechat_work: [
{ key: 'corpId', label: 'Corp ID' }, { key: 'corpId', labelKey: 'channels.fields.corpId' },
{ key: 'agentId', label: 'Agent ID' }, { key: 'agentId', labelKey: 'channels.fields.agentId' },
{ key: 'secret', label: 'Secret', type: 'password' }, { key: 'secret', labelKey: 'channels.fields.secret', type: 'password' },
], ],
voice_service: [ voice_service: [
{ key: 'provider', label: 'Provider' }, { key: 'provider', labelKey: 'channels.fields.provider' },
{ key: 'apiKey', label: 'API Key', type: 'password' }, { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
{ key: 'endpoint', label: 'Endpoint URL' }, { key: 'endpoint', labelKey: 'channels.fields.endpoint' },
], ],
}; };
const ALL_CHANNEL_TYPES: ChannelType[] = ['push', 'sms', 'voice_call', 'email', 'telegram', 'wechat_work', 'voice_service']; const ALL_CHANNEL_TYPES: ChannelType[] = ['push', 'sms', 'voice_call', 'email', 'telegram', 'wechat_work', 'voice_service'];
const CHANNEL_LABELS: Record<ChannelType, string> = { const CHANNEL_LABEL_KEYS: Record<ChannelType, string> = {
push: 'Push Notification', push: 'channels.types.push',
sms: 'SMS', sms: 'channels.types.sms',
voice_call: 'Voice Call', voice_call: 'channels.types.voice',
email: 'Email', email: 'channels.types.email',
telegram: 'Telegram', telegram: 'channels.types.telegram',
wechat_work: 'WeChat Work', wechat_work: 'channels.types.wechatWork',
voice_service: 'Voice Service', voice_service: 'channels.types.voiceService',
}; };
const SEVERITY_OPTIONS = ['critical', 'high', 'medium', 'low', 'info']; const SEVERITY_OPTIONS = ['critical', 'high', 'medium', 'low', 'info'];
const TABS = ['Channels', 'Contacts', 'Escalation Policies'] as const; const TABS = ['channels', 'contacts', 'escalationPolicies'] as const;
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
const TAB_LABEL_KEYS: Record<Tab, string> = {
channels: 'tabs.channels',
contacts: 'tabs.contacts',
escalationPolicies: 'tabs.escalationPolicies',
};
// ── Main Component ────────────────────────────────────────────────────────── // ── Main Component ──────────────────────────────────────────────────────────
export default function CommunicationPage() { export default function CommunicationPage() {
const [activeTab, setActiveTab] = useState<Tab>('Channels'); const { t } = useTranslation('communication');
const [activeTab, setActiveTab] = useState<Tab>('channels');
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6">Communication Settings</h1> <h1 className="text-2xl font-bold mb-6">{t('title')}</h1>
{/* Tab bar */} {/* Tab bar */}
<div className="flex border-b mb-6"> <div className="flex border-b mb-6">
{TABS.map((tab) => ( {TABS.map((tab) => (
@ -127,14 +135,14 @@ export default function CommunicationPage() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30'
)} )}
> >
{tab} {t(TAB_LABEL_KEYS[tab])}
</button> </button>
))} ))}
</div> </div>
{/* Tab content */} {/* Tab content */}
{activeTab === 'Channels' && <ChannelsTab />} {activeTab === 'channels' && <ChannelsTab />}
{activeTab === 'Contacts' && <ContactsTab />} {activeTab === 'contacts' && <ContactsTab />}
{activeTab === 'Escalation Policies' && <EscalationPoliciesTab />} {activeTab === 'escalationPolicies' && <EscalationPoliciesTab />}
</div> </div>
); );
} }
@ -142,6 +150,8 @@ export default function CommunicationPage() {
// ── Tab 1: Channels ───────────────────────────────────────────────────────── // ── Tab 1: Channels ─────────────────────────────────────────────────────────
function ChannelsTab() { function ChannelsTab() {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@ -170,19 +180,19 @@ function ChannelsTab() {
const channels = data?.data ?? []; const channels = data?.data ?? [];
if (isLoading) return <p className="text-muted-foreground">Loading channels...</p>; if (isLoading) return <p className="text-muted-foreground">{tc('loading')}</p>;
if (error) return <p className="text-red-500">Error loading channels: {(error as Error).message}</p>; if (error) return <p className="text-red-500">{tc('error')}: {(error as Error).message}</p>;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{channels.length === 0 && <p className="text-muted-foreground">No channels configured.</p>} {channels.length === 0 && <p className="text-muted-foreground">{tc('noData')}</p>}
{channels.map((ch) => ( {channels.map((ch) => (
<div key={ch.id} className="border rounded-lg bg-card"> <div key={ch.id} className="border rounded-lg bg-card">
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div> <div>
<p className="font-medium">{ch.name}</p> <p className="font-medium">{ch.name}</p>
<p className="text-xs text-muted-foreground">{CHANNEL_LABELS[ch.type] ?? ch.type}</p> <p className="text-xs text-muted-foreground">{CHANNEL_LABEL_KEYS[ch.type] ? t(CHANNEL_LABEL_KEYS[ch.type]) : ch.type}</p>
</div> </div>
<span <span
className={cn( className={cn(
@ -190,7 +200,7 @@ function ChannelsTab() {
ch.configured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700' ch.configured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
)} )}
> >
{ch.configured ? 'Configured' : 'Not Configured'} {ch.configured ? t('channels.configured') : t('channels.notConfigured')}
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -202,7 +212,7 @@ function ChannelsTab() {
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
ch.enabled ? 'bg-primary' : 'bg-muted' ch.enabled ? 'bg-primary' : 'bg-muted'
)} )}
title={ch.enabled ? 'Disable channel' : 'Enable channel'} title={ch.enabled ? tc('disabled') : tc('enabled')}
> >
<span <span
className={cn( className={cn(
@ -215,7 +225,7 @@ function ChannelsTab() {
onClick={() => setExpandedId(expandedId === ch.id ? null : ch.id)} onClick={() => setExpandedId(expandedId === ch.id ? null : ch.id)}
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
{expandedId === ch.id ? 'Hide Config' : 'Show Config'} {expandedId === ch.id ? t('channels.hideConfig') : t('channels.showConfig')}
</button> </button>
</div> </div>
</div> </div>
@ -242,6 +252,8 @@ function ChannelConfigEditor({
onSave: (config: Record<string, string>) => void; onSave: (config: Record<string, string>) => void;
isSaving: boolean; isSaving: boolean;
}) { }) {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const fields = CHANNEL_CONFIG_FIELDS[channel.type] ?? []; const fields = CHANNEL_CONFIG_FIELDS[channel.type] ?? [];
const [localConfig, setLocalConfig] = useState<Record<string, string>>({ ...channel.config }); const [localConfig, setLocalConfig] = useState<Record<string, string>>({ ...channel.config });
@ -249,7 +261,7 @@ function ChannelConfigEditor({
<div className="border-t p-4 space-y-3"> <div className="border-t p-4 space-y-3">
{fields.map((field) => ( {fields.map((field) => (
<div key={field.key}> <div key={field.key}>
<label className="block text-sm font-medium mb-1">{field.label}</label> <label className="block text-sm font-medium mb-1">{t(field.labelKey)}</label>
<input <input
type={field.type ?? 'text'} type={field.type ?? 'text'}
value={localConfig[field.key] ?? ''} value={localConfig[field.key] ?? ''}
@ -263,7 +275,7 @@ function ChannelConfigEditor({
disabled={isSaving} disabled={isSaving}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"
> >
{isSaving ? 'Saving...' : 'Save Configuration'} {isSaving ? tc('saving') : t('channels.saveConfiguration')}
</button> </button>
</div> </div>
); );
@ -272,6 +284,8 @@ function ChannelConfigEditor({
// ── Tab 2: Contacts ───────────────────────────────────────────────────────── // ── Tab 2: Contacts ─────────────────────────────────────────────────────────
function ContactsTab() { function ContactsTab() {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [editingContact, setEditingContact] = useState<Contact | null>(null); const [editingContact, setEditingContact] = useState<Contact | null>(null);
@ -321,29 +335,29 @@ function ContactsTab() {
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<p className="text-sm text-muted-foreground">{contacts.length} contact(s)</p> <p className="text-sm text-muted-foreground">{t('contacts.count', { count: contacts.length })}</p>
<button <button
onClick={handleOpenAdd} onClick={handleOpenAdd}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"
> >
Add Contact {t('contacts.addContact')}
</button> </button>
</div> </div>
{isLoading && <p className="text-muted-foreground">Loading contacts...</p>} {isLoading && <p className="text-muted-foreground">{tc('loading')}</p>}
{error && <p className="text-red-500">Error: {(error as Error).message}</p>} {error && <p className="text-red-500">{tc('error')}: {(error as Error).message}</p>}
{!isLoading && !error && ( {!isLoading && !error && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b text-left"> <tr className="border-b text-left">
<th className="py-2 pr-4 font-medium">Name</th> <th className="py-2 pr-4 font-medium">{t('contacts.table.name')}</th>
<th className="py-2 pr-4 font-medium">Email</th> <th className="py-2 pr-4 font-medium">{t('contacts.table.email')}</th>
<th className="py-2 pr-4 font-medium">Phone</th> <th className="py-2 pr-4 font-medium">{t('contacts.table.phone')}</th>
<th className="py-2 pr-4 font-medium">Role</th> <th className="py-2 pr-4 font-medium">{t('contacts.table.role')}</th>
<th className="py-2 pr-4 font-medium">Channels</th> <th className="py-2 pr-4 font-medium">{t('contacts.table.channels')}</th>
<th className="py-2 font-medium">Actions</th> <th className="py-2 font-medium">{t('contacts.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -370,16 +384,16 @@ function ContactsTab() {
onClick={() => handleOpenEdit(c)} onClick={() => handleOpenEdit(c)}
className="text-xs text-primary hover:underline" className="text-xs text-primary hover:underline"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm('Delete this contact?')) deleteMutation.mutate(c.id); if (confirm(tc('confirmDelete'))) deleteMutation.mutate(c.id);
}} }}
className="text-xs text-red-500 hover:underline" className="text-xs text-red-500 hover:underline"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -388,7 +402,7 @@ function ContactsTab() {
{contacts.length === 0 && ( {contacts.length === 0 && (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground"> <td colSpan={6} className="py-8 text-center text-muted-foreground">
No contacts found. Click &quot;Add Contact&quot; to create one. {t('contacts.empty')}
</td> </td>
</tr> </tr>
)} )}
@ -430,6 +444,8 @@ function ContactDialog({
onSave: (data: Omit<Contact, 'id'>) => void; onSave: (data: Omit<Contact, 'id'>) => void;
isSaving: boolean; isSaving: boolean;
}) { }) {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const [name, setName] = useState(contact?.name ?? ''); const [name, setName] = useState(contact?.name ?? '');
const [email, setEmail] = useState(contact?.email ?? ''); const [email, setEmail] = useState(contact?.email ?? '');
const [phone, setPhone] = useState(contact?.phone ?? ''); const [phone, setPhone] = useState(contact?.phone ?? '');
@ -448,26 +464,26 @@ function ContactDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border rounded-lg shadow-lg w-full max-w-lg p-6"> <div className="bg-card border rounded-lg shadow-lg w-full max-w-lg p-6">
<h2 className="text-lg font-semibold mb-4">{contact ? 'Edit Contact' : 'Add Contact'}</h2> <h2 className="text-lg font-semibold mb-4">{contact ? t('contacts.editContact') : t('contacts.addContact')}</h2>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">{t('contacts.form.name')}</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" /> <input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <label className="block text-sm font-medium mb-1">{t('contacts.form.email')}</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Phone</label> <label className="block text-sm font-medium mb-1">{t('contacts.form.phone')}</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm" /> <input value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('contacts.form.role')}</label>
<input value={role} onChange={(e) => setRole(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. admin, on-call, manager" /> <input value={role} onChange={(e) => setRole(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. admin, on-call, manager" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Channels</label> <label className="block text-sm font-medium mb-1">{t('contacts.form.channels')}</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ALL_CHANNEL_TYPES.map((ch) => ( {ALL_CHANNEL_TYPES.map((ch) => (
<button <button
@ -486,10 +502,10 @@ function ContactDialog({
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent"> <button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent">
Cancel {tc('cancel')}
</button> </button>
<button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"> <button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50">
{isSaving ? 'Saving...' : contact ? 'Update' : 'Create'} {isSaving ? tc('saving') : contact ? tc('save') : tc('create')}
</button> </button>
</div> </div>
</form> </form>
@ -501,6 +517,8 @@ function ContactDialog({
// ── Tab 3: Escalation Policies ────────────────────────────────────────────── // ── Tab 3: Escalation Policies ──────────────────────────────────────────────
function EscalationPoliciesTab() { function EscalationPoliciesTab() {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@ -543,14 +561,14 @@ function EscalationPoliciesTab() {
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<p className="text-sm text-muted-foreground">{policies.length} policy(ies)</p> <p className="text-sm text-muted-foreground">{t('escalationPolicies.count', { count: policies.length })}</p>
<button onClick={() => setShowDialog(true)} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm"> <button onClick={() => setShowDialog(true)} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm">
Add Policy {t('escalationPolicies.addPolicy')}
</button> </button>
</div> </div>
{isLoading && <p className="text-muted-foreground">Loading policies...</p>} {isLoading && <p className="text-muted-foreground">{tc('loading')}</p>}
{error && <p className="text-red-500">Error: {(error as Error).message}</p>} {error && <p className="text-red-500">{tc('error')}: {(error as Error).message}</p>}
<div className="space-y-3"> <div className="space-y-3">
{policies.map((policy) => ( {policies.map((policy) => (
@ -559,10 +577,10 @@ function EscalationPoliciesTab() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<p className="font-medium">{policy.name}</p> <p className="font-medium">{policy.name}</p>
<p className="text-xs text-muted-foreground">Severity: {policy.severity}</p> <p className="text-xs text-muted-foreground">{tc('severity')}: {policy.severity}</p>
</div> </div>
{policy.isDefault && ( {policy.isDefault && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">Default</span> <span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">{t('escalationPolicies.default')}</span>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -570,16 +588,16 @@ function EscalationPoliciesTab() {
onClick={() => setExpandedId(expandedId === policy.id ? null : policy.id)} onClick={() => setExpandedId(expandedId === policy.id ? null : policy.id)}
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
{expandedId === policy.id ? 'Collapse' : 'Edit Steps'} {expandedId === policy.id ? t('escalationPolicies.collapseSteps') : t('escalationPolicies.editSteps')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm('Delete this escalation policy?')) deleteMutation.mutate(policy.id); if (confirm(tc('confirmDelete'))) deleteMutation.mutate(policy.id);
}} }}
className="text-sm text-red-500 hover:underline" className="text-sm text-red-500 hover:underline"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -594,7 +612,7 @@ function EscalationPoliciesTab() {
</div> </div>
))} ))}
{!isLoading && policies.length === 0 && ( {!isLoading && policies.length === 0 && (
<p className="text-center text-muted-foreground py-8">No escalation policies. Click &quot;Add Policy&quot; to create one.</p> <p className="text-center text-muted-foreground py-8">{t('escalationPolicies.empty')}</p>
)} )}
</div> </div>
@ -621,6 +639,8 @@ function PolicyStepEditor({
onSave: (policy: EscalationPolicy) => void; onSave: (policy: EscalationPolicy) => void;
isSaving: boolean; isSaving: boolean;
}) { }) {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const [steps, setSteps] = useState<EscalationStep[]>(policy.steps); const [steps, setSteps] = useState<EscalationStep[]>(policy.steps);
const addStep = () => { const addStep = () => {
@ -665,17 +685,17 @@ function PolicyStepEditor({
return ( return (
<div className="border-t p-4 space-y-4"> <div className="border-t p-4 space-y-4">
<p className="text-sm font-medium">Escalation Steps</p> <p className="text-sm font-medium">{t('escalationPolicies.form.steps')}</p>
{steps.map((step, idx) => ( {steps.map((step, idx) => (
<div key={idx} className="border rounded-md p-3 space-y-2 bg-background"> <div key={idx} className="border rounded-md p-3 space-y-2 bg-background">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Step {step.stepNumber}</span> <span className="text-sm font-medium">{t('escalationPolicies.step.title', { number: step.stepNumber })}</span>
<button onClick={() => removeStep(idx)} className="text-xs text-red-500 hover:underline"> <button onClick={() => removeStep(idx)} className="text-xs text-red-500 hover:underline">
Remove {tc('remove')}
</button> </button>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Delay (minutes)</label> <label className="block text-xs text-muted-foreground mb-1">{t('escalationPolicies.step.delay')} ({t('escalationPolicies.step.delayMinutes')})</label>
<input <input
type="number" type="number"
min={0} min={0}
@ -685,7 +705,7 @@ function PolicyStepEditor({
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Channels</label> <label className="block text-xs text-muted-foreground mb-1">{t('escalationPolicies.step.channels')}</label>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{ALL_CHANNEL_TYPES.map((ch) => ( {ALL_CHANNEL_TYPES.map((ch) => (
<button <button
@ -703,9 +723,9 @@ function PolicyStepEditor({
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Contacts</label> <label className="block text-xs text-muted-foreground mb-1">{t('escalationPolicies.step.contacts')}</label>
{contacts.length === 0 ? ( {contacts.length === 0 ? (
<p className="text-xs text-muted-foreground">No contacts available. Add contacts first.</p> <p className="text-xs text-muted-foreground">{t('contacts.empty')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{contacts.map((c) => ( {contacts.map((c) => (
@ -728,14 +748,14 @@ function PolicyStepEditor({
))} ))}
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={addStep} className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent"> <button onClick={addStep} className="px-3 py-1.5 border rounded-md text-sm hover:bg-accent">
+ Add Step + {t('escalationPolicies.step.addStep')}
</button> </button>
<button <button
onClick={() => onSave({ ...policy, steps })} onClick={() => onSave({ ...policy, steps })}
disabled={isSaving} disabled={isSaving}
className="px-4 py-1.5 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50" className="px-4 py-1.5 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"
> >
{isSaving ? 'Saving...' : 'Save Steps'} {isSaving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -751,6 +771,8 @@ function PolicyDialog({
onSave: (data: Omit<EscalationPolicy, 'id'>) => void; onSave: (data: Omit<EscalationPolicy, 'id'>) => void;
isSaving: boolean; isSaving: boolean;
}) { }) {
const { t } = useTranslation('communication');
const { t: tc } = useTranslation('common');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [severity, setSeverity] = useState('critical'); const [severity, setSeverity] = useState('critical');
const [isDefault, setIsDefault] = useState(false); const [isDefault, setIsDefault] = useState(false);
@ -763,14 +785,14 @@ function PolicyDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6"> <div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6">
<h2 className="text-lg font-semibold mb-4">Add Escalation Policy</h2> <h2 className="text-lg font-semibold mb-4">{t('escalationPolicies.addPolicy')}</h2>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">{t('escalationPolicies.form.name')}</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. Critical Alert Policy" /> <input value={name} onChange={(e) => setName(e.target.value)} required className="w-full px-3 py-2 bg-input border rounded-md text-sm" placeholder="e.g. Critical Alert Policy" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Severity</label> <label className="block text-sm font-medium mb-1">{t('escalationPolicies.form.severity')}</label>
<select value={severity} onChange={(e) => setSeverity(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm"> <select value={severity} onChange={(e) => setSeverity(e.target.value)} className="w-full px-3 py-2 bg-input border rounded-md text-sm">
{SEVERITY_OPTIONS.map((s) => ( {SEVERITY_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option> <option key={s} value={s}>{s}</option>
@ -779,14 +801,14 @@ function PolicyDialog({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input type="checkbox" id="isDefault" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" /> <input type="checkbox" id="isDefault" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
<label htmlFor="isDefault" className="text-sm">Set as default policy</label> <label htmlFor="isDefault" className="text-sm">{t('escalationPolicies.form.setAsDefault')}</label>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent"> <button type="button" onClick={onClose} className="px-4 py-2 border rounded-md text-sm hover:bg-accent">
Cancel {tc('cancel')}
</button> </button>
<button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50"> <button type="submit" disabled={isSaving} className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90 disabled:opacity-50">
{isSaving ? 'Creating...' : 'Create Policy'} {isSaving ? tc('creating') : tc('create')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -112,6 +113,9 @@ function StatCard({ label, count, icon, loading }: StatCardProps) {
/* ---------- page ---------- */ /* ---------- page ---------- */
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useTranslation('dashboard');
const { t: tc } = useTranslation('common');
const { const {
data: serversRaw, data: serversRaw,
isLoading: serversLoading, isLoading: serversLoading,
@ -154,30 +158,30 @@ export default function DashboardPage() {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1> <h1 className="text-2xl font-bold mb-6">{t('title')}</h1>
{/* ---- stat cards ---- */} {/* ---- stat cards ---- */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard <StatCard
label="Total Servers" label={t('stats.totalServers')}
count={serversData?.total} count={serversData?.total}
icon={<Server className="h-5 w-5" />} icon={<Server className="h-5 w-5" />}
loading={serversLoading} loading={serversLoading}
/> />
<StatCard <StatCard
label="Active Alerts" label={t('stats.activeAlerts')}
count={alertsData?.total} count={alertsData?.total}
icon={<AlertTriangle className="h-5 w-5" />} icon={<AlertTriangle className="h-5 w-5" />}
loading={alertsLoading} loading={alertsLoading}
/> />
<StatCard <StatCard
label="Running Tasks" label={t('stats.runningTasks')}
count={tasksData?.total} count={tasksData?.total}
icon={<ListTodo className="h-5 w-5" />} icon={<ListTodo className="h-5 w-5" />}
loading={tasksLoading} loading={tasksLoading}
/> />
<StatCard <StatCard
label="Standing Orders" label={t('stats.standingOrders')}
count={standingOrdersData?.total} count={standingOrdersData?.total}
icon={<Clock className="h-5 w-5" />} icon={<Clock className="h-5 w-5" />}
loading={ordersLoading} loading={ordersLoading}
@ -186,20 +190,20 @@ export default function DashboardPage() {
{/* ---- recent alerts ---- */} {/* ---- recent alerts ---- */}
<div className="bg-card rounded-lg border p-4 mb-6"> <div className="bg-card rounded-lg border p-4 mb-6">
<h2 className="text-lg font-semibold mb-3">Recent Alerts</h2> <h2 className="text-lg font-semibold mb-3">{t('recentAlerts.title')}</h2>
{alertsLoading ? ( {alertsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">{tc('loading')}</p>
) : recentAlerts.length === 0 ? ( ) : recentAlerts.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent alerts.</p> <p className="text-sm text-muted-foreground">{t('recentAlerts.empty')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b text-left text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Severity</th> <th className="pb-2 pr-4 font-medium">{t('recentAlerts.severity')}</th>
<th className="pb-2 pr-4 font-medium">Message</th> <th className="pb-2 pr-4 font-medium">{t('recentAlerts.message')}</th>
<th className="pb-2 pr-4 font-medium">Server</th> <th className="pb-2 pr-4 font-medium">{t('recentAlerts.server')}</th>
<th className="pb-2 font-medium">Time</th> <th className="pb-2 font-medium">{t('recentAlerts.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -225,19 +229,19 @@ export default function DashboardPage() {
{/* ---- recent tasks ---- */} {/* ---- recent tasks ---- */}
<div className="bg-card rounded-lg border p-4"> <div className="bg-card rounded-lg border p-4">
<h2 className="text-lg font-semibold mb-3">Recent Tasks</h2> <h2 className="text-lg font-semibold mb-3">{t('recentTasks.title')}</h2>
{tasksLoading ? ( {tasksLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">{tc('loading')}</p>
) : recentTasks.length === 0 ? ( ) : recentTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent tasks.</p> <p className="text-sm text-muted-foreground">{t('recentTasks.empty')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b text-left text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Name</th> <th className="pb-2 pr-4 font-medium">{t('recentTasks.name')}</th>
<th className="pb-2 pr-4 font-medium">Status</th> <th className="pb-2 pr-4 font-medium">{t('recentTasks.status')}</th>
<th className="pb-2 font-medium">Created</th> <th className="pb-2 font-medium">{t('recentTasks.created')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
@ -69,19 +70,19 @@ const SEVERITY_STYLES: Record<Severity, string> = {
fatal: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', fatal: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
}; };
const CONDITIONS: { label: string; value: Condition }[] = [ const CONDITIONS: { labelKey: string; value: Condition }[] = [
{ label: '> (greater than)', value: '>' }, { labelKey: 'alertRules.conditions.greaterThan', value: '>' },
{ label: '< (less than)', value: '<' }, { labelKey: 'alertRules.conditions.lessThan', value: '<' },
{ label: '== (equal)', value: '==' }, { labelKey: 'alertRules.conditions.equal', value: '==' },
{ label: '>= (greater or equal)', value: '>=' }, { labelKey: 'alertRules.conditions.greaterOrEqual', value: '>=' },
{ label: '<= (less or equal)', value: '<=' }, { labelKey: 'alertRules.conditions.lessOrEqual', value: '<=' },
]; ];
const SEVERITIES: { label: string; value: Severity }[] = [ const SEVERITIES: { labelKey: string; value: Severity }[] = [
{ label: 'Info', value: 'info' }, { labelKey: 'alertRules.severities.info', value: 'info' },
{ label: 'Warning', value: 'warning' }, { labelKey: 'alertRules.severities.warning', value: 'warning' },
{ label: 'Critical', value: 'critical' }, { labelKey: 'alertRules.severities.critical', value: 'critical' },
{ label: 'Fatal', value: 'fatal' }, { labelKey: 'alertRules.severities.fatal', value: 'fatal' },
]; ];
const COMMON_METRICS = [ const COMMON_METRICS = [
@ -171,16 +172,17 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('monitoring');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Alert Rule</h2> <h2 className="text-lg font-semibold mb-2">{t('alertRules.deleteRule')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{name}</strong>? This will stop all future {t('alertRules.deleteConfirm')} <strong>{name}</strong>
alerts for this rule and cannot be undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -188,14 +190,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -249,6 +251,8 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function AlertRuleDetailPage() { export default function AlertRuleDetailPage() {
const { t } = useTranslation('monitoring');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -423,10 +427,10 @@ export default function AlertRuleDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Alert Rules {t('alertRules.detail.backToAlertRules')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading alert rule... {t('alertRules.detail.loading')}
</div> </div>
</div> </div>
); );
@ -454,10 +458,10 @@ export default function AlertRuleDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Alert Rules {t('alertRules.detail.backToAlertRules')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load alert rule: {(error as Error).message} {t('alertRules.detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -486,7 +490,7 @@ export default function AlertRuleDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Alert Rules {t('alertRules.detail.backToAlertRules')}
</button> </button>
{/* Page header */} {/* Page header */}
@ -502,7 +506,7 @@ export default function AlertRuleDetailPage() {
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
)} )}
> >
{rule.enabled ? 'Enabled' : 'Disabled'} {rule.enabled ? tc('enabled') : tc('disabled')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -529,13 +533,13 @@ export default function AlertRuleDetailPage() {
{/* Alert Rule Information Card */} {/* Alert Rule Information Card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Rule Configuration</h2> <h2 className="text-lg font-semibold">{t('alertRules.detail.ruleConfiguration')}</h2>
{!isEditing && ( {!isEditing && (
<button <button
onClick={startEditing} onClick={startEditing}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -546,7 +550,7 @@ export default function AlertRuleDetailPage() {
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('alertRules.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -565,7 +569,7 @@ export default function AlertRuleDetailPage() {
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => handleChange('description', e.target.value)} onChange={(e) => handleChange('description', e.target.value)}
@ -578,7 +582,7 @@ export default function AlertRuleDetailPage() {
{/* metric */} {/* metric */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Metric <span className="text-destructive">*</span> {t('alertRules.form.metric')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -604,7 +608,7 @@ export default function AlertRuleDetailPage() {
{/* condition + threshold */} {/* condition + threshold */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Condition</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.condition')}</label>
<select <select
value={form.condition} value={form.condition}
onChange={(e) => handleChange('condition', e.target.value)} onChange={(e) => handleChange('condition', e.target.value)}
@ -612,14 +616,14 @@ export default function AlertRuleDetailPage() {
> >
{CONDITIONS.map((c) => ( {CONDITIONS.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
{c.label} {t(c.labelKey)}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Threshold <span className="text-destructive">*</span> {t('alertRules.form.threshold')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="number" type="number"
@ -640,7 +644,7 @@ export default function AlertRuleDetailPage() {
{/* severity */} {/* severity */}
<div> <div>
<label className="block text-sm font-medium mb-1">Severity</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.severity')}</label>
<select <select
value={form.severity} value={form.severity}
onChange={(e) => handleChange('severity', e.target.value)} onChange={(e) => handleChange('severity', e.target.value)}
@ -648,7 +652,7 @@ export default function AlertRuleDetailPage() {
> >
{SEVERITIES.map((s) => ( {SEVERITIES.map((s) => (
<option key={s.value} value={s.value}> <option key={s.value} value={s.value}>
{s.label} {t(s.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -656,20 +660,20 @@ export default function AlertRuleDetailPage() {
{/* notifyChannels */} {/* notifyChannels */}
<div> <div>
<label className="block text-sm font-medium mb-1">Notify Channels</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.notifyChannels')}</label>
<input <input
type="text" type="text"
value={form.notifyChannels} value={form.notifyChannels}
onChange={(e) => handleChange('notifyChannels', e.target.value)} onChange={(e) => handleChange('notifyChannels', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="email, slack, webhook (comma-separated)" placeholder={t('alertRules.form.notifyChannelsPlaceholder')}
/> />
</div> </div>
{/* cooldownMinutes */} {/* cooldownMinutes */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Cooldown (minutes) <span className="text-destructive">*</span> {t('alertRules.form.cooldownMinutes')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="number" type="number"
@ -690,7 +694,7 @@ export default function AlertRuleDetailPage() {
{/* Update error */} {/* Update error */}
{updateMutation.isError && ( {updateMutation.isError && (
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to update rule: {(updateMutation.error as Error).message} {t('alertRules.detail.failedToUpdate')} {(updateMutation.error as Error).message}
</div> </div>
)} )}
@ -702,7 +706,7 @@ export default function AlertRuleDetailPage() {
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -710,20 +714,20 @@ export default function AlertRuleDetailPage() {
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
/* ---------- Read-only info display ---------- */ /* ---------- Read-only info display ---------- */
<dl className="divide-y"> <dl className="divide-y">
<InfoRow label="Name" value={rule.name} /> <InfoRow label={t('alertRules.form.name')} value={rule.name} />
<InfoRow <InfoRow
label="Description" label={t('alertRules.form.description')}
value={rule.description || 'No description'} value={rule.description || t('alertRules.detail.noDescription')}
/> />
<InfoRow <InfoRow
label="Metric" label={t('alertRules.form.metric')}
value={ value={
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded"> <code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{rule.metric} {rule.metric}
@ -731,7 +735,7 @@ export default function AlertRuleDetailPage() {
} }
/> />
<InfoRow <InfoRow
label="Condition" label={t('alertRules.form.condition')}
value={ value={
<span className="font-mono text-sm"> <span className="font-mono text-sm">
{rule.metric} {rule.condition} {rule.threshold} {rule.metric} {rule.condition} {rule.threshold}
@ -739,17 +743,17 @@ export default function AlertRuleDetailPage() {
} }
/> />
<InfoRow <InfoRow
label="Threshold" label={t('alertRules.form.threshold')}
value={ value={
<span className="tabular-nums">{rule.threshold}</span> <span className="tabular-nums">{rule.threshold}</span>
} }
/> />
<InfoRow <InfoRow
label="Severity" label={tc('severity')}
value={<SeverityBadge severity={rule.severity} />} value={<SeverityBadge severity={rule.severity} />}
/> />
<InfoRow <InfoRow
label="Status" label={tc('status')}
value={ value={
<span <span
className={cn( className={cn(
@ -759,12 +763,12 @@ export default function AlertRuleDetailPage() {
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
)} )}
> >
{rule.enabled ? 'Enabled' : 'Disabled'} {rule.enabled ? tc('enabled') : tc('disabled')}
</span> </span>
} }
/> />
<InfoRow <InfoRow
label="Notify Channels" label={t('alertRules.detail.notifyChannels')}
value={ value={
rule.notifyChannels.length > 0 ? ( rule.notifyChannels.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -778,16 +782,16 @@ export default function AlertRuleDetailPage() {
))} ))}
</div> </div>
) : ( ) : (
'None configured' t('alertRules.detail.noneConfigured')
) )
} }
/> />
<InfoRow <InfoRow
label="Cooldown" label={t('alertRules.detail.cooldown')}
value={`${rule.cooldownMinutes} minute${rule.cooldownMinutes !== 1 ? 's' : ''}`} value={`${rule.cooldownMinutes} minute${rule.cooldownMinutes !== 1 ? 's' : ''}`}
/> />
<InfoRow label="Created" value={formatDate(rule.createdAt)} /> <InfoRow label={tc('created')} value={formatDate(rule.createdAt)} />
<InfoRow label="Updated" value={formatDate(rule.updatedAt)} /> <InfoRow label={tc('updated')} value={formatDate(rule.updatedAt)} />
</dl> </dl>
)} )}
</div> </div>
@ -795,7 +799,7 @@ export default function AlertRuleDetailPage() {
{/* Recent Alert Events */} {/* Recent Alert Events */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4"> <h2 className="text-lg font-semibold mb-4">
Recent Alert Events {t('alertRules.detail.recentAlertEvents')}
<span className="text-sm font-normal text-muted-foreground ml-2"> <span className="text-sm font-normal text-muted-foreground ml-2">
({alertEvents.length}) ({alertEvents.length})
</span> </span>
@ -803,20 +807,20 @@ export default function AlertRuleDetailPage() {
{alertEvents.length === 0 ? ( {alertEvents.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center"> <p className="text-sm text-muted-foreground py-8 text-center">
No alert events triggered by this rule yet. {t('alertRules.detail.noAlertEvents')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Severity</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.severity')}</th>
<th className="text-left px-3 py-2 font-medium">Metric</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.metric')}</th>
<th className="text-left px-3 py-2 font-medium">Value</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.value')}</th>
<th className="text-left px-3 py-2 font-medium">Threshold</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.threshold')}</th>
<th className="text-left px-3 py-2 font-medium">Message</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.message')}</th>
<th className="text-left px-3 py-2 font-medium">Status</th> <th className="text-left px-3 py-2 font-medium">{t('alertRules.detail.eventTable.status')}</th>
<th className="text-right px-3 py-2 font-medium">Triggered</th> <th className="text-right px-3 py-2 font-medium">{t('alertRules.detail.eventTable.triggered')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -843,11 +847,11 @@ export default function AlertRuleDetailPage() {
<td className="px-3 py-2"> <td className="px-3 py-2">
{event.resolvedAt ? ( {event.resolvedAt ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Resolved {t('alertRules.detail.eventStatus.resolved')}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
Active {t('alertRules.detail.eventStatus.active')}
</span> </span>
)} )}
</td> </td>
@ -867,14 +871,14 @@ export default function AlertRuleDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Rule Summary */} {/* Rule Summary */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Rule Summary</h2> <h2 className="text-lg font-semibold mb-4">{t('alertRules.detail.ruleSummary')}</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Severity</span> <span className="text-sm text-muted-foreground">{tc('severity')}</span>
<SeverityBadge severity={rule.severity} /> <SeverityBadge severity={rule.severity} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span> <span className="text-sm text-muted-foreground">{tc('status')}</span>
<span <span
className={cn( className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
@ -883,15 +887,15 @@ export default function AlertRuleDetailPage() {
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
)} )}
> >
{rule.enabled ? 'Enabled' : 'Disabled'} {rule.enabled ? tc('enabled') : tc('disabled')}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cooldown</span> <span className="text-sm text-muted-foreground">{t('alertRules.detail.cooldown')}</span>
<span className="text-sm font-medium">{rule.cooldownMinutes}m</span> <span className="text-sm font-medium">{rule.cooldownMinutes}m</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total Events</span> <span className="text-sm text-muted-foreground">{t('alertRules.detail.totalEvents')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{eventsData?.total ?? alertEvents.length} {eventsData?.total ?? alertEvents.length}
</span> </span>
@ -900,7 +904,7 @@ export default function AlertRuleDetailPage() {
{/* Rule expression display */} {/* Rule expression display */}
<div className="mt-4 p-3 bg-muted/50 rounded-md"> <div className="mt-4 p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground mb-1">Rule Expression</p> <p className="text-xs text-muted-foreground mb-1">{t('alertRules.detail.ruleExpression')}</p>
<p className="text-sm font-mono"> <p className="text-sm font-mono">
{rule.metric} {rule.condition} {rule.threshold} {rule.metric} {rule.condition} {rule.threshold}
</p> </p>
@ -909,10 +913,10 @@ export default function AlertRuleDetailPage() {
{/* Notify Channels */} {/* Notify Channels */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Notify Channels</h2> <h2 className="text-lg font-semibold mb-4">{t('alertRules.detail.notifyChannels')}</h2>
{rule.notifyChannels.length === 0 ? ( {rule.notifyChannels.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No notification channels configured. {t('alertRules.detail.noNotificationChannels')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -930,7 +934,7 @@ export default function AlertRuleDetailPage() {
{/* Quick Actions card */} {/* Quick Actions card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('alertRules.detail.quickActions')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{/* Toggle enabled */} {/* Toggle enabled */}
<button <button
@ -944,10 +948,10 @@ export default function AlertRuleDetailPage() {
)} )}
> >
{toggleMutation.isPending {toggleMutation.isPending
? 'Updating...' ? t('alertRules.detail.updating')
: rule.enabled : rule.enabled
? 'Disable Rule' ? t('alertRules.detail.disableRule')
: 'Enable Rule'} : t('alertRules.detail.enableRule')}
</button> </button>
{/* Edit */} {/* Edit */}
@ -955,7 +959,7 @@ export default function AlertRuleDetailPage() {
onClick={startEditing} onClick={startEditing}
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors" className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors"
> >
Edit Rule {t('alertRules.detail.editRule')}
</button> </button>
{/* Delete */} {/* Delete */}
@ -964,29 +968,29 @@ export default function AlertRuleDetailPage() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50" className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50"
> >
Delete Rule {t('alertRules.detail.deleteRule')}
</button> </button>
</div> </div>
</div> </div>
{/* Metadata */} {/* Metadata */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Metadata</h2> <h2 className="text-lg font-semibold mb-4">{t('alertRules.detail.metadata')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Rule ID</span> <span className="text-sm text-muted-foreground">{t('alertRules.detail.ruleId')}</span>
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{rule.id} {rule.id}
</code> </code>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span> <span className="text-sm text-muted-foreground">{tc('created')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(rule.createdAt)} {formatDate(rule.createdAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Updated</span> <span className="text-sm text-muted-foreground">{tc('updated')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(rule.updatedAt)} {formatDate(rule.updatedAt)}
</span> </span>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -48,27 +49,27 @@ interface AlertRulesResponse {
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const METRICS: { label: string; value: Metric }[] = [ const METRICS: { labelKey: string; value: Metric }[] = [
{ label: 'CPU Usage', value: 'cpu_usage' }, { labelKey: 'alertRules.metrics.cpuUsage', value: 'cpu_usage' },
{ label: 'Memory Usage', value: 'memory_usage' }, { labelKey: 'alertRules.metrics.memoryUsage', value: 'memory_usage' },
{ label: 'Disk Usage', value: 'disk_usage' }, { labelKey: 'alertRules.metrics.diskUsage', value: 'disk_usage' },
{ label: 'Network Latency', value: 'network_latency' }, { labelKey: 'alertRules.metrics.networkLatency', value: 'network_latency' },
{ label: 'HTTP Error Rate', value: 'http_error_rate' }, { labelKey: 'alertRules.metrics.httpErrorRate', value: 'http_error_rate' },
{ label: 'Custom', value: 'custom' }, { labelKey: 'alertRules.metrics.custom', value: 'custom' },
]; ];
const CONDITIONS: { label: string; value: Condition }[] = [ const CONDITIONS: { labelKey: string; value: Condition }[] = [
{ label: '> (greater than)', value: 'gt' }, { labelKey: 'alertRules.conditions.greaterThan', value: 'gt' },
{ label: '< (less than)', value: 'lt' }, { labelKey: 'alertRules.conditions.lessThan', value: 'lt' },
{ label: '>= (greater or equal)', value: 'gte' }, { labelKey: 'alertRules.conditions.greaterOrEqual', value: 'gte' },
{ label: '<= (less or equal)', value: 'lte' }, { labelKey: 'alertRules.conditions.lessOrEqual', value: 'lte' },
{ label: '= (equal)', value: 'eq' }, { labelKey: 'alertRules.conditions.equal', value: 'eq' },
]; ];
const SEVERITIES: { label: string; value: Severity }[] = [ const SEVERITIES: { labelKey: string; value: Severity }[] = [
{ label: 'Critical', value: 'critical' }, { labelKey: 'alertRules.severities.critical', value: 'critical' },
{ label: 'Warning', value: 'warning' }, { labelKey: 'alertRules.severities.warning', value: 'warning' },
{ label: 'Info', value: 'info' }, { labelKey: 'alertRules.severities.info', value: 'info' },
]; ];
const DURATIONS: { label: string; value: string }[] = [ const DURATIONS: { label: string; value: string }[] = [
@ -88,13 +89,13 @@ const CONDITION_SYMBOLS: Record<Condition, string> = {
eq: '=', eq: '=',
}; };
const METRIC_LABELS: Record<Metric, string> = { const METRIC_LABEL_KEYS: Record<Metric, string> = {
cpu_usage: 'CPU Usage', cpu_usage: 'alertRules.metrics.cpuUsage',
memory_usage: 'Memory Usage', memory_usage: 'alertRules.metrics.memoryUsage',
disk_usage: 'Disk Usage', disk_usage: 'alertRules.metrics.diskUsage',
network_latency: 'Network Latency', network_latency: 'alertRules.metrics.networkLatency',
http_error_rate: 'HTTP Error Rate', http_error_rate: 'alertRules.metrics.httpErrorRate',
custom: 'Custom', custom: 'alertRules.metrics.custom',
}; };
const EMPTY_FORM: AlertRuleFormData = { const EMPTY_FORM: AlertRuleFormData = {
@ -190,6 +191,8 @@ function AlertRuleDialog({
onChange: (field: keyof AlertRuleFormData, value: string | number | boolean) => void; onChange: (field: keyof AlertRuleFormData, value: string | number | boolean) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('monitoring');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -205,7 +208,7 @@ function AlertRuleDialog({
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('alertRules.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -224,7 +227,7 @@ function AlertRuleDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -236,7 +239,7 @@ function AlertRuleDialog({
{/* metric */} {/* metric */}
<div> <div>
<label className="block text-sm font-medium mb-1">Metric</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.metric')}</label>
<select <select
value={form.metric} value={form.metric}
onChange={(e) => onChange('metric', e.target.value)} onChange={(e) => onChange('metric', e.target.value)}
@ -244,7 +247,7 @@ function AlertRuleDialog({
> >
{METRICS.map((m) => ( {METRICS.map((m) => (
<option key={m.value} value={m.value}> <option key={m.value} value={m.value}>
{m.label} {t(m.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -254,7 +257,7 @@ function AlertRuleDialog({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* condition */} {/* condition */}
<div> <div>
<label className="block text-sm font-medium mb-1">Condition</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.condition')}</label>
<select <select
value={form.condition} value={form.condition}
onChange={(e) => onChange('condition', e.target.value)} onChange={(e) => onChange('condition', e.target.value)}
@ -262,7 +265,7 @@ function AlertRuleDialog({
> >
{CONDITIONS.map((c) => ( {CONDITIONS.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
{c.label} {t(c.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -271,7 +274,7 @@ function AlertRuleDialog({
{/* threshold */} {/* threshold */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Threshold <span className="text-destructive">*</span> {t('alertRules.form.threshold')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="number" type="number"
@ -310,7 +313,7 @@ function AlertRuleDialog({
{/* severity */} {/* severity */}
<div> <div>
<label className="block text-sm font-medium mb-1">Severity</label> <label className="block text-sm font-medium mb-1">{t('alertRules.form.severity')}</label>
<select <select
value={form.severity} value={form.severity}
onChange={(e) => onChange('severity', e.target.value)} onChange={(e) => onChange('severity', e.target.value)}
@ -318,7 +321,7 @@ function AlertRuleDialog({
> >
{SEVERITIES.map((s) => ( {SEVERITIES.map((s) => (
<option key={s.value} value={s.value}> <option key={s.value} value={s.value}>
{s.label} {t(s.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -342,7 +345,7 @@ function AlertRuleDialog({
{/* enabled toggle */} {/* enabled toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm font-medium">Enabled</label> <label className="text-sm font-medium">{t('alertRules.form.enabled')}</label>
<button <button
type="button" type="button"
onClick={() => onChange('enabled', !form.enabled)} onClick={() => onChange('enabled', !form.enabled)}
@ -371,7 +374,7 @@ function AlertRuleDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -379,7 +382,7 @@ function AlertRuleDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -404,15 +407,17 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('monitoring');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Alert Rule</h2> <h2 className="text-lg font-semibold mb-2">{t('alertRules.deleteRule')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{ruleName}</strong>? This action cannot be undone. {t('alertRules.deleteConfirm')} <strong>{ruleName}</strong>
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -420,14 +425,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -440,6 +445,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function AlertRulesPage() { export default function AlertRulesPage() {
const { t } = useTranslation('monitoring');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -584,30 +591,30 @@ export default function AlertRulesPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Alert Rules</h1> <h1 className="text-2xl font-bold">{t('alertRules.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Configure alert rules to monitor server metrics and receive notifications. {t('alertRules.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Rule {t('alertRules.addRule')}
</button> </button>
</div> </div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load alert rules: {(error as Error).message} {t('alertRules.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading alert rules... {t('alertRules.loading')}
</div> </div>
)} )}
@ -618,13 +625,13 @@ export default function AlertRulesPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Metric</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.metric')}</th>
<th className="text-left px-4 py-3 font-medium">Condition</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.condition')}</th>
<th className="text-left px-4 py-3 font-medium">Threshold</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.threshold')}</th>
<th className="text-left px-4 py-3 font-medium">Severity</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.severity')}</th>
<th className="text-left px-4 py-3 font-medium">Enabled</th> <th className="text-left px-4 py-3 font-medium">{t('alertRules.table.enabled')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('alertRules.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -634,7 +641,7 @@ export default function AlertRulesPage() {
colSpan={7} colSpan={7}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No alert rules found. {t('alertRules.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -653,7 +660,7 @@ export default function AlertRulesPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted"> <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted">
{METRIC_LABELS[rule.metric] ?? rule.metric} {t(METRIC_LABEL_KEYS[rule.metric]) ?? rule.metric}
</span> </span>
</td> </td>
<td className="px-4 py-3 font-mono text-xs"> <td className="px-4 py-3 font-mono text-xs">
@ -688,13 +695,13 @@ export default function AlertRulesPage() {
onClick={() => openEdit(rule)} onClick={() => openEdit(rule)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(rule)} onClick={() => setDeleteTarget(rule)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -710,7 +717,7 @@ export default function AlertRulesPage() {
{/* Add / Edit dialog */} {/* Add / Edit dialog */}
<AlertRuleDialog <AlertRuleDialog
open={dialogOpen} open={dialogOpen}
title={editingRule ? 'Edit Alert Rule' : 'Add Alert Rule'} title={editingRule ? t('alertRules.editRule') : t('alertRules.addRule')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -35,18 +36,18 @@ type RefreshInterval = 0 | 10 | 30 | 60;
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [ const STATUS_FILTERS: { labelKey: string; value: StatusFilter }[] = [
{ label: 'All', value: 'all' }, { labelKey: 'healthChecks.filters.all', value: 'all' },
{ label: 'Healthy', value: 'healthy' }, { labelKey: 'healthChecks.filters.healthy', value: 'healthy' },
{ label: 'Degraded', value: 'degraded' }, { labelKey: 'healthChecks.filters.degraded', value: 'degraded' },
{ label: 'Down', value: 'down' }, { labelKey: 'healthChecks.filters.down', value: 'down' },
]; ];
const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [ const REFRESH_OPTIONS: { labelKey: string; value: RefreshInterval }[] = [
{ label: 'Off', value: 0 }, { labelKey: 'healthChecks.refreshOptions.off', value: 0 },
{ label: '10s', value: 10 }, { labelKey: 'healthChecks.refreshOptions.10s', value: 10 },
{ label: '30s', value: 30 }, { labelKey: 'healthChecks.refreshOptions.30s', value: 30 },
{ label: '60s', value: 60 }, { labelKey: 'healthChecks.refreshOptions.60s', value: 60 },
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -123,6 +124,7 @@ function StatCard({ label, value, dotColor }: { label: string; value: number; do
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function HealthCheckCard({ check }: { check: HealthCheckResult }) { function HealthCheckCard({ check }: { check: HealthCheckResult }) {
const { t } = useTranslation('monitoring');
return ( return (
<div className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"> <div className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start justify-between gap-3 mb-3">
@ -134,21 +136,21 @@ function HealthCheckCard({ check }: { check: HealthCheckResult }) {
<p className="text-xs text-muted-foreground font-mono mt-0.5 ml-[18px]">{check.serverHost}</p> <p className="text-xs text-muted-foreground font-mono mt-0.5 ml-[18px]">{check.serverHost}</p>
</div> </div>
<span className={cn('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium uppercase flex-shrink-0', getCheckTypeBadgeColor(check.checkType))}> <span className={cn('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium uppercase flex-shrink-0', getCheckTypeBadgeColor(check.checkType))}>
{check.checkType} {t(`healthChecks.checkTypes.${check.checkType}`)}
</span> </span>
</div> </div>
<div className="grid grid-cols-3 gap-3 text-center"> <div className="grid grid-cols-3 gap-3 text-center">
<div> <div>
<p className="text-xs text-muted-foreground mb-0.5">Latency</p> <p className="text-xs text-muted-foreground mb-0.5">{t('healthChecks.card.latency')}</p>
<p className={cn('text-sm font-semibold', getLatencyColor(check.latencyMs))}>{check.latencyMs}ms</p> <p className={cn('text-sm font-semibold', getLatencyColor(check.latencyMs))}>{check.latencyMs}ms</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground mb-0.5">Uptime (24h)</p> <p className="text-xs text-muted-foreground mb-0.5">{t('healthChecks.card.uptime24h')}</p>
<p className={cn('text-sm font-semibold', getUptimeColor(check.uptimePercent))}>{check.uptimePercent.toFixed(1)}%</p> <p className={cn('text-sm font-semibold', getUptimeColor(check.uptimePercent))}>{check.uptimePercent.toFixed(1)}%</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground mb-0.5">Last Check</p> <p className="text-xs text-muted-foreground mb-0.5">{t('healthChecks.card.lastCheck')}</p>
<p className="text-sm font-medium text-muted-foreground">{formatRelativeTime(check.lastCheckedAt)}</p> <p className="text-sm font-medium text-muted-foreground">{formatRelativeTime(check.lastCheckedAt)}</p>
</div> </div>
</div> </div>
@ -167,6 +169,7 @@ function HealthCheckCard({ check }: { check: HealthCheckResult }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function HealthChecksPage() { export default function HealthChecksPage() {
const { t } = useTranslation('monitoring');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@ -209,24 +212,24 @@ export default function HealthChecksPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Health Checks</h1> <h1 className="text-2xl font-bold">{t('healthChecks.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor server health and availability</p> <p className="text-sm text-muted-foreground mt-1">{t('healthChecks.subtitle')}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Auto-refresh:</span> <span className="text-sm text-muted-foreground">{t('healthChecks.autoRefresh')}</span>
<select <select
value={refreshInterval} value={refreshInterval}
onChange={(e) => handleRefreshChange(Number(e.target.value) as RefreshInterval)} onChange={(e) => handleRefreshChange(Number(e.target.value) as RefreshInterval)}
className="px-2 py-1.5 bg-background border border-input rounded-md text-sm" className="px-2 py-1.5 bg-background border border-input rounded-md text-sm"
> >
{REFRESH_OPTIONS.map((opt) => ( {REFRESH_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))} ))}
</select> </select>
{refreshInterval > 0 && ( {refreshInterval > 0 && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Live {t('healthChecks.live')}
</span> </span>
)} )}
</div> </div>
@ -234,10 +237,10 @@ export default function HealthChecksPage() {
{/* Summary stats */} {/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Servers" value={stats.total} /> <StatCard label={t('healthChecks.stats.totalServers')} value={stats.total} />
<StatCard label="Healthy" value={stats.healthy} dotColor="bg-green-500" /> <StatCard label={t('healthChecks.stats.healthy')} value={stats.healthy} dotColor="bg-green-500" />
<StatCard label="Degraded" value={stats.degraded} dotColor="bg-yellow-500" /> <StatCard label={t('healthChecks.stats.degraded')} value={stats.degraded} dotColor="bg-yellow-500" />
<StatCard label="Down" value={stats.down} dotColor="bg-red-500" /> <StatCard label={t('healthChecks.stats.down')} value={stats.down} dotColor="bg-red-500" />
</div> </div>
{/* Filter tabs */} {/* Filter tabs */}
@ -253,7 +256,7 @@ export default function HealthChecksPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{filter.label} {t(filter.labelKey)}
{filter.value !== 'all' && ( {filter.value !== 'all' && (
<span className="ml-1.5 text-xs text-muted-foreground"> <span className="ml-1.5 text-xs text-muted-foreground">
{filter.value === 'healthy' && stats.healthy} {filter.value === 'healthy' && stats.healthy}
@ -267,19 +270,19 @@ export default function HealthChecksPage() {
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load health checks: {(error as Error).message} {t('healthChecks.loadError')} {(error as Error).message}
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center">Loading health checks...</div> <div className="text-sm text-muted-foreground py-12 text-center">{t('healthChecks.loading')}</div>
)} )}
{!isLoading && !error && ( {!isLoading && !error && (
<> <>
{filteredChecks.length === 0 ? ( {filteredChecks.length === 0 ? (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
{statusFilter === 'all' ? 'No health checks found.' : `No servers with status "${statusFilter}" found.`} {statusFilter === 'all' ? t('healthChecks.empty') : t('healthChecks.noMatchingStatus', { status: statusFilter })}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -47,17 +48,17 @@ type SortDirection = 'asc' | 'desc';
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ENV_FILTERS: { label: string; value: EnvironmentFilter }[] = [ const ENV_FILTERS: { labelKey: string; value: EnvironmentFilter }[] = [
{ label: 'All', value: 'all' }, { labelKey: 'metrics.filters.all', value: 'all' },
{ label: 'Dev', value: 'dev' }, { labelKey: 'metrics.filters.dev', value: 'dev' },
{ label: 'Staging', value: 'staging' }, { labelKey: 'metrics.filters.staging', value: 'staging' },
{ label: 'Prod', value: 'prod' }, { labelKey: 'metrics.filters.prod', value: 'prod' },
]; ];
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [ const STATUS_FILTERS: { labelKey: string; value: StatusFilter }[] = [
{ label: 'All', value: 'all' }, { labelKey: 'metrics.filters.all', value: 'all' },
{ label: 'Online', value: 'online' }, { labelKey: 'metrics.filters.online', value: 'online' },
{ label: 'Offline', value: 'offline' }, { labelKey: 'metrics.filters.offline', value: 'offline' },
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -230,6 +231,7 @@ function SortableHeader({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function MetricsPage() { export default function MetricsPage() {
const { t } = useTranslation('monitoring');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -330,9 +332,9 @@ export default function MetricsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Metrics Dashboard</h1> <h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Monitor server performance and resource utilization {t('metrics.subtitle')}
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -343,12 +345,12 @@ export default function MetricsPage() {
onChange={(e) => setAutoRefresh(e.target.checked)} onChange={(e) => setAutoRefresh(e.target.checked)}
className="h-4 w-4 rounded border-input text-primary focus:ring-primary cursor-pointer" className="h-4 w-4 rounded border-input text-primary focus:ring-primary cursor-pointer"
/> />
Auto-refresh (30s) {t('metrics.autoRefresh')}
</label> </label>
{autoRefresh && ( {autoRefresh && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Live {t('metrics.live')}
</span> </span>
)} )}
</div> </div>
@ -358,29 +360,29 @@ export default function MetricsPage() {
{overview && ( {overview && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
<OverviewCard <OverviewCard
label="Total Servers" label={t('metrics.overview.totalServers')}
value={overview.totalServers} value={overview.totalServers}
/> />
<OverviewCard <OverviewCard
label="Online" label={t('metrics.overview.online')}
value={`${overview.onlinePercent.toFixed(1)}`} value={`${overview.onlinePercent.toFixed(1)}`}
suffix="%" suffix="%"
color={overview.onlinePercent >= 95 ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'} color={overview.onlinePercent >= 95 ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'}
/> />
<OverviewCard <OverviewCard
label="Avg CPU" label={t('metrics.overview.avgCpu')}
value={`${overview.avgCpuPercent.toFixed(1)}`} value={`${overview.avgCpuPercent.toFixed(1)}`}
suffix="%" suffix="%"
color={getPercentColor(overview.avgCpuPercent)} color={getPercentColor(overview.avgCpuPercent)}
/> />
<OverviewCard <OverviewCard
label="Avg Memory" label={t('metrics.overview.avgMemory')}
value={`${overview.avgMemoryPercent.toFixed(1)}`} value={`${overview.avgMemoryPercent.toFixed(1)}`}
suffix="%" suffix="%"
color={getPercentColor(overview.avgMemoryPercent)} color={getPercentColor(overview.avgMemoryPercent)}
/> />
<OverviewCard <OverviewCard
label="Alerts Today" label={t('metrics.overview.alertsToday')}
value={overview.totalAlertsToday} value={overview.totalAlertsToday}
color={overview.totalAlertsToday > 0 ? 'text-red-600 dark:text-red-400' : undefined} color={overview.totalAlertsToday > 0 ? 'text-red-600 dark:text-red-400' : undefined}
/> />
@ -407,7 +409,7 @@ export default function MetricsPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="Search by hostname..." placeholder={t('metrics.searchPlaceholder')}
/> />
</div> </div>
@ -424,7 +426,7 @@ export default function MetricsPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{f.label} {t(f.labelKey)}
</button> </button>
))} ))}
</div> </div>
@ -442,7 +444,7 @@ export default function MetricsPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{f.label} {t(f.labelKey)}
</button> </button>
))} ))}
</div> </div>
@ -451,14 +453,14 @@ export default function MetricsPage() {
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load server metrics: {(error as Error).message} {t('metrics.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{serversLoading && ( {serversLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading server metrics... {t('metrics.loading')}
</div> </div>
)} )}
@ -470,43 +472,43 @@ export default function MetricsPage() {
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<SortableHeader <SortableHeader
label="Hostname" label={t('metrics.table.hostname')}
field="hostname" field="hostname"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
/> />
<th className="text-left px-4 py-3 font-medium">Env</th> <th className="text-left px-4 py-3 font-medium">{t('metrics.table.env')}</th>
<SortableHeader <SortableHeader
label="Status" label={t('metrics.table.status')}
field="status" field="status"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
/> />
<SortableHeader <SortableHeader
label="CPU %" label={t('metrics.table.cpuPercent')}
field="cpuPercent" field="cpuPercent"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
/> />
<SortableHeader <SortableHeader
label="Memory %" label={t('metrics.table.memoryPercent')}
field="memoryPercent" field="memoryPercent"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
/> />
<SortableHeader <SortableHeader
label="Disk %" label={t('metrics.table.diskPercent')}
field="diskPercent" field="diskPercent"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
/> />
<SortableHeader <SortableHeader
label="Last Checked" label={t('metrics.table.lastChecked')}
field="lastCheckedAt" field="lastCheckedAt"
currentSort={sortField} currentSort={sortField}
currentDirection={sortDirection} currentDirection={sortDirection}
@ -523,8 +525,8 @@ export default function MetricsPage() {
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
{allServers.length === 0 {allServers.length === 0
? 'No server metrics available.' ? t('metrics.empty')
: 'No servers match the current filters.'} : t('metrics.noMatchingFilters')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -566,7 +568,7 @@ export default function MetricsPage() {
{/* Results count */} {/* Results count */}
{!serversLoading && !error && allServers.length > 0 && ( {!serversLoading && !error && allServers.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
Showing {filteredServers.length} of {allServers.length} server{allServers.length !== 1 ? 's' : ''} {t('metrics.showing', { count: filteredServers.length, total: allServers.length })}
</div> </div>
)} )}
</div> </div>

View File

@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -54,13 +55,6 @@ const AVAILABLE_TOOLS = [
'ping', 'ping',
]; ];
const RISK_LEVEL_LABELS: Record<number, string> = {
0: 'L0 - Info',
1: 'L1 - Low',
2: 'L2 - Medium',
3: 'L3 - High',
};
const RISK_LEVEL_STYLES: Record<number, string> = { const RISK_LEVEL_STYLES: Record<number, string> = {
0: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 0: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
1: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 1: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
@ -85,6 +79,7 @@ const EXECUTION_STATUS_STYLES: Record<string, string> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function RiskBadge({ level }: { level: number }) { function RiskBadge({ level }: { level: number }) {
const { t } = useTranslation('runbooks');
return ( return (
<span <span
className={cn( className={cn(
@ -92,7 +87,7 @@ function RiskBadge({ level }: { level: number }) {
RISK_LEVEL_STYLES[level] ?? RISK_LEVEL_STYLES[0], RISK_LEVEL_STYLES[level] ?? RISK_LEVEL_STYLES[0],
)} )}
> >
{RISK_LEVEL_LABELS[level] ?? `L${level}`} {t(`riskLevels.${level}`) ?? `L${level}`}
</span> </span>
); );
} }
@ -182,16 +177,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('runbooks');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Runbook</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{name}</strong>? This action {t('deleteDialog.message')}
cannot be undone. All execution history will also be removed.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -199,14 +196,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -241,6 +238,8 @@ function formatDuration(ms?: number): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function RunbookDetailPage() { export default function RunbookDetailPage() {
const { t } = useTranslation('runbooks');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
@ -363,7 +362,7 @@ export default function RunbookDetailPage() {
const handleToggleTool = useCallback((tool: string) => { const handleToggleTool = useCallback((tool: string) => {
setToolsDraft((prev) => setToolsDraft((prev) =>
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool], prev.includes(tool) ? prev.filter((item) => item !== tool) : [...prev, tool],
); );
}, []); }, []);
@ -401,7 +400,7 @@ export default function RunbookDetailPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading runbook... {t('detail.loading')}
</div> </div>
); );
} }
@ -414,10 +413,10 @@ export default function RunbookDetailPage() {
onClick={() => router.push('/runbooks')} onClick={() => router.push('/runbooks')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Runbooks &larr; {t('detail.backToRunbooks')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load runbook: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -431,10 +430,10 @@ export default function RunbookDetailPage() {
onClick={() => router.push('/runbooks')} onClick={() => router.push('/runbooks')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Runbooks &larr; {t('detail.backToRunbooks')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Runbook not found. {t('detail.notFound')}
</div> </div>
</div> </div>
); );
@ -450,13 +449,13 @@ export default function RunbookDetailPage() {
onClick={() => router.push('/runbooks')} onClick={() => router.push('/runbooks')}
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
> >
&larr; Back &larr; {t('detail.back')}
</button> </button>
<div className="h-6 w-px bg-border" /> <div className="h-6 w-px bg-border" />
<div> <div>
<h1 className="text-2xl font-bold">{runbook.name}</h1> <h1 className="text-2xl font-bold">{runbook.name}</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
{runbook.description || 'No description'} {runbook.description || t('detail.noDescription')}
</p> </p>
</div> </div>
</div> </div>
@ -471,7 +470,7 @@ export default function RunbookDetailPage() {
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
)} )}
> >
{runbook.enabled ? 'Enabled' : 'Disabled'} {runbook.enabled ? tc('enabled') : tc('disabled')}
</span> </span>
</div> </div>
</div> </div>
@ -493,17 +492,17 @@ export default function RunbookDetailPage() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Overview card */} {/* Overview card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Overview</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.overview')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Name {t('form.name')}
</label> </label>
<p className="text-sm mt-1">{runbook.name}</p> <p className="text-sm mt-1">{runbook.name}</p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Trigger Type {t('detail.triggerType')}
</label> </label>
<p className="mt-1"> <p className="mt-1">
<TriggerBadge type={runbook.triggerType} /> <TriggerBadge type={runbook.triggerType} />
@ -511,15 +510,15 @@ export default function RunbookDetailPage() {
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Description {tc('description')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{runbook.description || 'No description provided.'} {runbook.description || t('detail.noDescription')}
</p> </p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Created {tc('created')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{formatDate(runbook.createdAt)} {formatDate(runbook.createdAt)}
@ -527,7 +526,7 @@ export default function RunbookDetailPage() {
</div> </div>
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Updated {tc('updated')}
</label> </label>
<p className="text-sm mt-1 text-muted-foreground"> <p className="text-sm mt-1 text-muted-foreground">
{formatDate(runbook.updatedAt)} {formatDate(runbook.updatedAt)}
@ -539,7 +538,7 @@ export default function RunbookDetailPage() {
{/* Prompt Template section */} {/* Prompt Template section */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Prompt Template</h2> <h2 className="text-lg font-semibold">{t('detail.promptTemplate')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditingPrompt ? ( {isEditingPrompt ? (
<> <>
@ -547,14 +546,14 @@ export default function RunbookDetailPage() {
onClick={handleCancelEditPrompt} onClick={handleCancelEditPrompt}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={handleSavePrompt} onClick={handleSavePrompt}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{updateMutation.isPending ? 'Saving...' : 'Save'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</> </>
) : ( ) : (
@ -562,7 +561,7 @@ export default function RunbookDetailPage() {
onClick={handleStartEditPrompt} onClick={handleStartEditPrompt}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -577,7 +576,7 @@ export default function RunbookDetailPage() {
/> />
) : ( ) : (
<div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]"> <div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap min-h-[300px]">
{runbook.promptTemplate || 'No prompt template defined.'} {runbook.promptTemplate || t('detail.noPrompt')}
</div> </div>
)} )}
</div> </div>
@ -585,7 +584,7 @@ export default function RunbookDetailPage() {
{/* Allowed Tools section */} {/* Allowed Tools section */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Allowed Tools</h2> <h2 className="text-lg font-semibold">{t('detail.allowedTools')}</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditingTools ? ( {isEditingTools ? (
<> <>
@ -593,14 +592,14 @@ export default function RunbookDetailPage() {
onClick={handleCancelEditTools} onClick={handleCancelEditTools}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={handleSaveTools} onClick={handleSaveTools}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{updateMutation.isPending ? 'Saving...' : 'Save'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</> </>
) : ( ) : (
@ -608,7 +607,7 @@ export default function RunbookDetailPage() {
onClick={handleStartEditTools} onClick={handleStartEditTools}
className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1.5 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -635,7 +634,7 @@ export default function RunbookDetailPage() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{runbook.allowedTools.length === 0 ? ( {runbook.allowedTools.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tools configured. {t('detail.noTools')}
</p> </p>
) : ( ) : (
runbook.allowedTools.map((tool) => ( runbook.allowedTools.map((tool) => (
@ -653,18 +652,18 @@ export default function RunbookDetailPage() {
{/* Execution History */} {/* Execution History */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Execution History</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.executionHistory')}</h2>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Date</th> <th className="text-left px-4 py-3 font-medium">{t('detail.executionTable.date')}</th>
<th className="text-left px-4 py-3 font-medium">Status</th> <th className="text-left px-4 py-3 font-medium">{t('detail.executionTable.status')}</th>
<th className="text-left px-4 py-3 font-medium"> <th className="text-left px-4 py-3 font-medium">
Duration {t('detail.executionTable.duration')}
</th> </th>
<th className="text-left px-4 py-3 font-medium"> <th className="text-left px-4 py-3 font-medium">
Triggered By {t('detail.executionTable.triggeredBy')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -675,7 +674,7 @@ export default function RunbookDetailPage() {
colSpan={4} colSpan={4}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No executions yet. {t('detail.noExecutions')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -709,12 +708,12 @@ export default function RunbookDetailPage() {
<div className="lg:col-span-1 space-y-6"> <div className="lg:col-span-1 space-y-6">
{/* Configuration card */} {/* Configuration card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Configuration</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.configuration')}</h2>
<div className="space-y-5"> <div className="space-y-5">
{/* Trigger type */} {/* Trigger type */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Trigger Type {t('detail.triggerType')}
</label> </label>
<p className="mt-1.5"> <p className="mt-1.5">
<TriggerBadge type={runbook.triggerType} /> <TriggerBadge type={runbook.triggerType} />
@ -724,7 +723,7 @@ export default function RunbookDetailPage() {
{/* Max Risk Level */} {/* Max Risk Level */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Max Risk Level {t('detail.maxRiskLevel')}
</label> </label>
<p className="mt-1.5"> <p className="mt-1.5">
<RiskBadge level={runbook.maxRiskLevel} /> <RiskBadge level={runbook.maxRiskLevel} />
@ -734,29 +733,29 @@ export default function RunbookDetailPage() {
{/* Auto-Approve toggle */} {/* Auto-Approve toggle */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
Auto-Approve {t('detail.autoApprove')}
</label> </label>
<ToggleSwitch <ToggleSwitch
checked={runbook.autoApprove} checked={runbook.autoApprove}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
onChange={handleToggleAutoApprove} onChange={handleToggleAutoApprove}
label={runbook.autoApprove ? 'Enabled' : 'Disabled'} label={runbook.autoApprove ? tc('enabled') : tc('disabled')}
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Auto-approve actions within the configured risk level. {t('detail.autoApproveDescription')}
</p> </p>
</div> </div>
{/* Enabled toggle */} {/* Enabled toggle */}
<div> <div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5"> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-1.5">
Status {t('detail.statusLabel')}
</label> </label>
<ToggleSwitch <ToggleSwitch
checked={runbook.enabled} checked={runbook.enabled}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
onChange={handleToggleEnabled} onChange={handleToggleEnabled}
label={runbook.enabled ? 'Enabled' : 'Disabled'} label={runbook.enabled ? tc('enabled') : tc('disabled')}
/> />
</div> </div>
</div> </div>
@ -764,7 +763,7 @@ export default function RunbookDetailPage() {
{/* Quick Actions card */} {/* Quick Actions card */}
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.quickActions')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{/* Execute Now */} {/* Execute Now */}
<button <button
@ -775,11 +774,11 @@ export default function RunbookDetailPage() {
'bg-primary text-primary-foreground hover:bg-primary/90', 'bg-primary text-primary-foreground hover:bg-primary/90',
)} )}
> >
{executeMutation.isPending ? 'Executing...' : 'Execute Now'} {executeMutation.isPending ? t('detail.executing') : t('detail.executeNow')}
</button> </button>
{!runbook.enabled && ( {!runbook.enabled && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable the runbook to execute it. {t('detail.enableToExecute')}
</p> </p>
)} )}
@ -789,7 +788,7 @@ export default function RunbookDetailPage() {
disabled={duplicateMutation.isPending} disabled={duplicateMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50" className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent font-medium transition-colors disabled:opacity-50"
> >
{duplicateMutation.isPending ? 'Duplicating...' : 'Duplicate'} {duplicateMutation.isPending ? t('detail.duplicating') : t('detail.duplicate')}
</button> </button>
{/* Delete */} {/* Delete */}
@ -798,7 +797,7 @@ export default function RunbookDetailPage() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50" className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 font-medium transition-colors disabled:opacity-50"
> >
Delete Runbook {t('detail.deleteRunbook')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -40,23 +41,25 @@ const emptyForm: RunbookFormData = {
autoApprove: false, autoApprove: false,
}; };
const TRIGGER_OPTIONS: { value: RunbookFormData['triggerType']; label: string }[] = [
{ value: 'manual', label: 'Manual' },
{ value: 'alert', label: 'Alert' },
{ value: 'scheduled', label: 'Scheduled' },
];
const RISK_LEVELS = [
{ value: 0, label: '0 - Info' },
{ value: 1, label: '1 - Low' },
{ value: 2, label: '2 - Medium' },
{ value: 3, label: '3 - High' },
];
/* ---------- Component ---------- */ /* ---------- Component ---------- */
export default function RunbooksPage() { export default function RunbooksPage() {
const { t } = useTranslation('runbooks');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const TRIGGER_OPTIONS: { value: RunbookFormData['triggerType']; label: string }[] = [
{ value: 'manual', label: t('triggerTypes.manual') },
{ value: 'alert', label: t('triggerTypes.alert') },
{ value: 'scheduled', label: t('triggerTypes.scheduled') },
];
const RISK_LEVELS = [
{ value: 0, label: t('riskLevels.0') },
{ value: 1, label: t('riskLevels.1') },
{ value: 2, label: t('riskLevels.2') },
{ value: 3, label: t('riskLevels.3') },
];
/* Dialog state */ /* Dialog state */
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@ -165,26 +168,26 @@ export default function RunbooksPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Runbooks</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage operations runbooks and automation scripts. {t('subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openCreate} onClick={openCreate}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
> >
New Runbook {t('newRunbook')}
</button> </button>
</div> </div>
{/* Loading / Error */} {/* Loading / Error */}
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm py-12 text-center">Loading runbooks...</div> <div className="text-muted-foreground text-sm py-12 text-center">{t('loading')}</div>
)} )}
{error && ( {error && (
<div className="text-destructive text-sm py-4"> <div className="text-destructive text-sm py-4">
Failed to load runbooks: {(error as Error).message} {t('loadError')} {(error as Error).message}
</div> </div>
)} )}
@ -194,19 +197,19 @@ export default function RunbooksPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Description</th> <th className="text-left px-4 py-3 font-medium">{t('table.description')}</th>
<th className="text-left px-4 py-3 font-medium">Trigger</th> <th className="text-left px-4 py-3 font-medium">{t('table.trigger')}</th>
<th className="text-center px-4 py-3 font-medium">Max Risk</th> <th className="text-center px-4 py-3 font-medium">{t('table.maxRisk')}</th>
<th className="text-center px-4 py-3 font-medium">Auto-Approve</th> <th className="text-center px-4 py-3 font-medium">{t('table.autoApprove')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{runbooks.length === 0 ? ( {runbooks.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
No runbooks yet. Click &quot;New Runbook&quot; to create one. {t('empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -241,7 +244,7 @@ export default function RunbooksPage() {
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors', 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
rb.autoApprove ? 'bg-primary' : 'bg-muted', rb.autoApprove ? 'bg-primary' : 'bg-muted',
)} )}
title={rb.autoApprove ? 'Auto-approve enabled' : 'Auto-approve disabled'} title={rb.autoApprove ? t('form.autoApprove') + ' ' + tc('enabled').toLowerCase() : t('form.autoApprove') + ' ' + tc('disabled').toLowerCase()}
> >
<span <span
className={cn( className={cn(
@ -256,7 +259,7 @@ export default function RunbooksPage() {
onClick={() => openEdit(rb)} onClick={() => openEdit(rb)}
className="text-xs text-primary hover:underline" className="text-xs text-primary hover:underline"
> >
Edit {tc('edit')}
</button> </button>
{deleteConfirmId === rb.id ? ( {deleteConfirmId === rb.id ? (
<> <>
@ -265,13 +268,13 @@ export default function RunbooksPage() {
className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80" className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Confirm {tc('confirm')}
</button> </button>
<button <button
onClick={() => setDeleteConfirmId(null)} onClick={() => setDeleteConfirmId(null)}
className="text-xs text-muted-foreground hover:underline" className="text-xs text-muted-foreground hover:underline"
> >
Cancel {tc('cancel')}
</button> </button>
</> </>
) : ( ) : (
@ -279,7 +282,7 @@ export default function RunbooksPage() {
onClick={() => setDeleteConfirmId(rb.id)} onClick={() => setDeleteConfirmId(rb.id)}
className="text-xs text-destructive-foreground hover:underline" className="text-xs text-destructive-foreground hover:underline"
> >
Delete {tc('delete')}
</button> </button>
)} )}
</td> </td>
@ -301,7 +304,7 @@ export default function RunbooksPage() {
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4"> <div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4">
<div className="flex items-center justify-between px-6 pt-6 pb-2"> <div className="flex items-center justify-between px-6 pt-6 pb-2">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{editingId ? 'Edit Runbook' : 'New Runbook'} {editingId ? tc('edit') : t('newRunbook')}
</h2> </h2>
<button <button
onClick={closeDialog} onClick={closeDialog}
@ -314,7 +317,7 @@ export default function RunbooksPage() {
<form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4"> <form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4">
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">{t('form.name')}</label>
<input <input
type="text" type="text"
required required
@ -327,7 +330,7 @@ export default function RunbooksPage() {
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={(e) => setForm({ ...form, description: e.target.value })}
@ -339,7 +342,7 @@ export default function RunbooksPage() {
{/* Trigger Type */} {/* Trigger Type */}
<div> <div>
<label className="block text-sm font-medium mb-1">Trigger Type</label> <label className="block text-sm font-medium mb-1">{t('form.triggerType')}</label>
<select <select
value={form.triggerType} value={form.triggerType}
onChange={(e) => onChange={(e) =>
@ -357,7 +360,7 @@ export default function RunbooksPage() {
{/* Prompt Template */} {/* Prompt Template */}
<div> <div>
<label className="block text-sm font-medium mb-1">Prompt Template</label> <label className="block text-sm font-medium mb-1">{t('form.promptTemplate')}</label>
<textarea <textarea
value={form.promptTemplate} value={form.promptTemplate}
onChange={(e) => setForm({ ...form, promptTemplate: e.target.value })} onChange={(e) => setForm({ ...form, promptTemplate: e.target.value })}
@ -369,7 +372,7 @@ export default function RunbooksPage() {
{/* Allowed Tools */} {/* Allowed Tools */}
<div> <div>
<label className="block text-sm font-medium mb-1">Allowed Tools</label> <label className="block text-sm font-medium mb-1">{t('form.allowedTools')}</label>
<input <input
type="text" type="text"
value={form.allowedTools} value={form.allowedTools}
@ -382,7 +385,7 @@ export default function RunbooksPage() {
{/* Max Risk Level */} {/* Max Risk Level */}
<div> <div>
<label className="block text-sm font-medium mb-1">Max Risk Level</label> <label className="block text-sm font-medium mb-1">{t('form.maxRiskLevel')}</label>
<div className="flex gap-4"> <div className="flex gap-4">
{RISK_LEVELS.map((rl) => ( {RISK_LEVELS.map((rl) => (
<label key={rl.value} className="flex items-center gap-1.5 text-sm cursor-pointer"> <label key={rl.value} className="flex items-center gap-1.5 text-sm cursor-pointer">
@ -408,7 +411,7 @@ export default function RunbooksPage() {
onChange={(e) => setForm({ ...form, autoApprove: e.target.checked })} onChange={(e) => setForm({ ...form, autoApprove: e.target.checked })}
className="accent-primary h-4 w-4" className="accent-primary h-4 w-4"
/> />
Auto-approve actions within risk level {t('form.autoApprove')}
</label> </label>
</div> </div>
@ -426,14 +429,14 @@ export default function RunbooksPage() {
onClick={closeDialog} onClick={closeDialog}
className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors" className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{isSaving ? 'Saving...' : editingId ? 'Update Runbook' : 'Create Runbook'} {isSaving ? tc('saving') : editingId ? tc('save') : tc('create')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -45,10 +46,10 @@ interface EditFormData {
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const AUTH_TYPE_LABELS: Record<Credential['authType'], string> = { const AUTH_TYPE_LABEL_KEYS: Record<Credential['authType'], string> = {
password: 'Password', password: 'credentials.authTypes.password',
ssh_key: 'SSH Key', ssh_key: 'credentials.authTypes.sshKey',
ssh_key_with_passphrase: 'SSH Key + Passphrase', ssh_key_with_passphrase: 'credentials.authTypes.sshKeyPassphrase',
}; };
const EMPTY_FORM: CredentialFormData = { const EMPTY_FORM: CredentialFormData = {
@ -71,6 +72,7 @@ const EMPTY_EDIT_FORM: EditFormData = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function AuthTypeBadge({ authType }: { authType: Credential['authType'] }) { function AuthTypeBadge({ authType }: { authType: Credential['authType'] }) {
const { t } = useTranslation('security');
const styles: Record<Credential['authType'], string> = { const styles: Record<Credential['authType'], string> = {
password: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', password: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
ssh_key: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', ssh_key: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
@ -84,7 +86,7 @@ function AuthTypeBadge({ authType }: { authType: Credential['authType'] }) {
styles[authType], styles[authType],
)} )}
> >
{AUTH_TYPE_LABELS[authType]} {t(AUTH_TYPE_LABEL_KEYS[authType])}
</span> </span>
); );
} }
@ -110,6 +112,8 @@ function AddCredentialDialog({
onChange: (field: keyof CredentialFormData, value: string) => void; onChange: (field: keyof CredentialFormData, value: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -119,13 +123,13 @@ function AddCredentialDialog({
{/* dialog */} {/* dialog */}
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">Add Credential</h2> <h2 className="text-lg font-semibold mb-4">{t('credentials.addCredential')}</h2>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1"> <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('credentials.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -145,7 +149,7 @@ function AddCredentialDialog({
{/* username */} {/* username */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Username <span className="text-destructive">*</span> {t('credentials.form.username')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -164,15 +168,15 @@ function AddCredentialDialog({
{/* auth type */} {/* auth type */}
<div> <div>
<label className="block text-sm font-medium mb-1">Auth Type</label> <label className="block text-sm font-medium mb-1">{t('credentials.form.authType')}</label>
<select <select
value={form.authType} value={form.authType}
onChange={(e) => onChange('authType', e.target.value)} onChange={(e) => onChange('authType', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
> >
<option value="password">Password</option> <option value="password">{t('credentials.authTypes.password')}</option>
<option value="ssh_key">SSH Key</option> <option value="ssh_key">{t('credentials.authTypes.sshKey')}</option>
<option value="ssh_key_with_passphrase">SSH Key with Passphrase</option> <option value="ssh_key_with_passphrase">{t('credentials.authTypes.sshKeyPassphrase')}</option>
</select> </select>
</div> </div>
@ -180,7 +184,7 @@ function AddCredentialDialog({
{form.authType === 'password' && ( {form.authType === 'password' && (
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Password <span className="text-destructive">*</span> {t('credentials.form.password')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="password" type="password"
@ -202,7 +206,7 @@ function AddCredentialDialog({
{(form.authType === 'ssh_key' || form.authType === 'ssh_key_with_passphrase') && ( {(form.authType === 'ssh_key' || form.authType === 'ssh_key_with_passphrase') && (
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Private Key <span className="text-destructive">*</span> {t('credentials.form.privateKey')} <span className="text-destructive">*</span>
</label> </label>
<textarea <textarea
value={form.privateKey} value={form.privateKey}
@ -224,7 +228,7 @@ function AddCredentialDialog({
{form.authType === 'ssh_key_with_passphrase' && ( {form.authType === 'ssh_key_with_passphrase' && (
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Passphrase <span className="text-destructive">*</span> {t('credentials.form.passphrase')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="password" type="password"
@ -244,7 +248,7 @@ function AddCredentialDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{tc('description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -263,7 +267,7 @@ function AddCredentialDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -271,7 +275,7 @@ function AddCredentialDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -300,6 +304,8 @@ function EditCredentialDialog({
onChange: (field: keyof EditFormData, value: string) => void; onChange: (field: keyof EditFormData, value: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -309,18 +315,17 @@ function EditCredentialDialog({
{/* dialog */} {/* dialog */}
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">Edit Credential</h2> <h2 className="text-lg font-semibold mb-4">{t('credentials.editCredential')}</h2>
<p className="text-xs text-muted-foreground mb-4 p-3 bg-muted rounded-md"> <p className="text-xs text-muted-foreground mb-4 p-3 bg-muted rounded-md">
For security reasons, credential values (passwords and private keys) cannot be modified. {t('credentials.encryptionWarning')}
To change authentication details, delete and recreate the credential.
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('credentials.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -339,7 +344,7 @@ function EditCredentialDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{tc('description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -358,7 +363,7 @@ function EditCredentialDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -366,7 +371,7 @@ function EditCredentialDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -393,27 +398,29 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Credential</h2> <h2 className="text-lg font-semibold mb-2">{t('credentials.deleteCredential')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Are you sure you want to delete <strong>{credentialName}</strong>? This action cannot be undone. {tc('confirmDelete')} <strong>{credentialName}</strong>
</p> </p>
{associatedServers > 0 && ( {associatedServers > 0 && (
<div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive"> <div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive">
<strong>Warning:</strong> This credential is currently associated with{' '} <strong>{tc('warning')}:</strong> This credential is currently associated with{' '}
<strong>{associatedServers}</strong> server{associatedServers !== 1 ? 's' : ''}. <strong>{associatedServers}</strong> server{associatedServers !== 1 ? 's' : ''}.
Deleting it will remove the credential from those servers and may prevent SSH connections. Deleting it will remove the credential from those servers and may prevent SSH connections.
</div> </div>
)} )}
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground"> <div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
Encrypted credential data will be permanently destroyed and cannot be recovered. {t('credentials.encryptionWarning')}
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@ -422,14 +429,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -442,6 +449,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function CredentialsPage() { export default function CredentialsPage() {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -658,16 +667,16 @@ export default function CredentialsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Credentials</h1> <h1 className="text-2xl font-bold">{t('credentials.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage SSH credentials for server connections {t('credentials.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Credential {t('credentials.addCredential')}
</button> </button>
</div> </div>
@ -690,7 +699,7 @@ export default function CredentialsPage() {
</svg> </svg>
</div> </div>
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800 dark:text-yellow-200">
Credentials are encrypted with AES-256-GCM. Private keys and passwords are never exposed after creation. {t('credentials.securityNotice')}
</p> </p>
</div> </div>
</div> </div>
@ -698,14 +707,14 @@ export default function CredentialsPage() {
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load credentials: {(error as Error).message} {t('credentials.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading credentials... {t('credentials.loading')}
</div> </div>
)} )}
@ -716,12 +725,12 @@ export default function CredentialsPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('credentials.table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Type</th> <th className="text-left px-4 py-3 font-medium">{t('credentials.table.type')}</th>
<th className="text-left px-4 py-3 font-medium">Username</th> <th className="text-left px-4 py-3 font-medium">{t('credentials.table.username')}</th>
<th className="text-left px-4 py-3 font-medium">Associated Servers</th> <th className="text-left px-4 py-3 font-medium">{t('credentials.table.associatedServers')}</th>
<th className="text-left px-4 py-3 font-medium">Created</th> <th className="text-left px-4 py-3 font-medium">{t('credentials.table.created')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('credentials.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -731,7 +740,7 @@ export default function CredentialsPage() {
colSpan={6} colSpan={6}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No credentials found. Add one to get started. {t('credentials.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -783,19 +792,19 @@ export default function CredentialsPage() {
disabled={testingId === credential.id} disabled={testingId === credential.id}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
> >
{testingId === credential.id ? 'Testing...' : 'Test'} {testingId === credential.id ? 'Testing...' : t('credentials.testCredential')}
</button> </button>
<button <button
onClick={() => openEdit(credential)} onClick={() => openEdit(credential)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(credential)} onClick={() => setDeleteTarget(credential)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -57,6 +58,7 @@ const ACTION_COLORS: Record<Permission['action'], string> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ActionBadge({ action }: { action: Permission['action'] }) { function ActionBadge({ action }: { action: Permission['action'] }) {
const { t } = useTranslation('security');
return ( return (
<span <span
className={cn( className={cn(
@ -64,7 +66,7 @@ function ActionBadge({ action }: { action: Permission['action'] }) {
ACTION_COLORS[action], ACTION_COLORS[action],
)} )}
> >
{action} {t(`permissions.actionTypes.${action}`)}
</span> </span>
); );
} }
@ -82,31 +84,33 @@ function PermissionInfoDialog({
permission: Permission | null; permission: Permission | null;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open || !permission) return null; if (!open || !permission) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">Permission Details</h2> <h2 className="text-lg font-semibold mb-4">{t('permissions.detailDialog.title')}</h2>
<dl className="space-y-3"> <dl className="space-y-3">
<div> <div>
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Key</dt> <dt className="text-xs text-muted-foreground uppercase tracking-wide">{t('permissions.detailDialog.key')}</dt>
<dd className="text-sm font-medium font-mono mt-0.5">{permission.key}</dd> <dd className="text-sm font-medium font-mono mt-0.5">{permission.key}</dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Resource</dt> <dt className="text-xs text-muted-foreground uppercase tracking-wide">{t('permissions.detailDialog.resource')}</dt>
<dd className="text-sm font-medium capitalize mt-0.5">{permission.resource}</dd> <dd className="text-sm font-medium capitalize mt-0.5">{permission.resource}</dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Action</dt> <dt className="text-xs text-muted-foreground uppercase tracking-wide">{t('permissions.detailDialog.action')}</dt>
<dd className="mt-0.5"> <dd className="mt-0.5">
<ActionBadge action={permission.action} /> <ActionBadge action={permission.action} />
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground uppercase tracking-wide">Description</dt> <dt className="text-xs text-muted-foreground uppercase tracking-wide">{t('permissions.detailDialog.description')}</dt>
<dd className="text-sm mt-0.5">{permission.description || '--'}</dd> <dd className="text-sm mt-0.5">{permission.description || '--'}</dd>
</div> </div>
</dl> </dl>
@ -116,7 +120,7 @@ function PermissionInfoDialog({
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
> >
Close {tc('close')}
</button> </button>
</div> </div>
</div> </div>
@ -129,6 +133,8 @@ function PermissionInfoDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function PermissionsPage() { export default function PermissionsPage() {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -223,16 +229,16 @@ export default function PermissionsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Permissions</h1> <h1 className="text-2xl font-bold">{t('permissions.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
View and manage the permission matrix across roles {t('permissions.subtitle')}
</p> </p>
</div> </div>
</div> </div>
{/* Resource filter */} {/* Resource filter */}
<div className="flex flex-wrap items-center gap-2 mb-6"> <div className="flex flex-wrap items-center gap-2 mb-6">
<span className="text-sm text-muted-foreground">Resource:</span> <span className="text-sm text-muted-foreground">{t('permissions.resource')}:</span>
<div className="flex gap-1 p-1 bg-muted rounded-lg flex-wrap"> <div className="flex gap-1 p-1 bg-muted rounded-lg flex-wrap">
<button <button
onClick={() => setResourceFilter('all')} onClick={() => setResourceFilter('all')}
@ -243,7 +249,7 @@ export default function PermissionsPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
All {tc('all')}
</button> </button>
{resources.map((resource) => ( {resources.map((resource) => (
<button <button
@ -265,14 +271,14 @@ export default function PermissionsPage() {
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load permissions: {(error as Error).message} {t('permissions.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading permissions matrix... {t('permissions.loading')}
</div> </div>
)} )}
@ -281,7 +287,7 @@ export default function PermissionsPage() {
<div className="space-y-6"> <div className="space-y-6">
{Object.keys(groupedPermissions).length === 0 ? ( {Object.keys(groupedPermissions).length === 0 ? (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
No permissions found. {t('permissions.empty')}
</div> </div>
) : ( ) : (
Object.entries(groupedPermissions).map(([resource, perms]) => ( Object.entries(groupedPermissions).map(([resource, perms]) => (
@ -294,9 +300,9 @@ export default function PermissionsPage() {
<thead> <thead>
<tr className="border-b bg-muted/30"> <tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium min-w-[200px]"> <th className="text-left px-4 py-3 font-medium min-w-[200px]">
Permission {t('permissions.permission')}
</th> </th>
<th className="text-left px-4 py-3 font-medium">Action</th> <th className="text-left px-4 py-3 font-medium">{t('permissions.action')}</th>
{roles.map((role) => ( {roles.map((role) => (
<th <th
key={role.id} key={role.id}
@ -362,22 +368,22 @@ export default function PermissionsPage() {
{/* Summary */} {/* Summary */}
{!isLoading && !error && permissions.length > 0 && ( {!isLoading && !error && permissions.length > 0 && (
<div className="mt-6 p-4 bg-muted/30 rounded-lg border"> <div className="mt-6 p-4 bg-muted/30 rounded-lg border">
<h3 className="text-sm font-semibold mb-2">Summary</h3> <h3 className="text-sm font-semibold mb-2">{t('permissions.summary.title')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div> <div>
<p className="text-xs text-muted-foreground">Total Permissions</p> <p className="text-xs text-muted-foreground">{t('permissions.summary.totalPermissions')}</p>
<p className="text-lg font-bold">{permissions.length}</p> <p className="text-lg font-bold">{permissions.length}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Total Roles</p> <p className="text-xs text-muted-foreground">{t('permissions.summary.totalRoles')}</p>
<p className="text-lg font-bold">{roles.length}</p> <p className="text-lg font-bold">{roles.length}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Resources</p> <p className="text-xs text-muted-foreground">{t('permissions.summary.resources')}</p>
<p className="text-lg font-bold">{resources.length}</p> <p className="text-lg font-bold">{resources.length}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Granted</p> <p className="text-xs text-muted-foreground">{t('permissions.summary.granted')}</p>
<p className="text-lg font-bold"> <p className="text-lg font-bold">
{matrixEntries.filter((e) => e.granted).length} {matrixEntries.filter((e) => e.granted).length}
</p> </p>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -44,32 +45,32 @@ type PermissionMatrix = Record<Role, Permission[]>;
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const RISK_LEVELS: { value: RiskRule['riskLevel']; label: string; color: string }[] = [ const RISK_LEVELS: { value: RiskRule['riskLevel']; labelKey: string; color: string }[] = [
{ value: 0, label: '0 - None', color: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300' }, { value: 0, labelKey: 'riskRules.riskLevels.0', color: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300' },
{ value: 1, label: '1 - Low', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' }, { value: 1, labelKey: 'riskRules.riskLevels.1', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
{ value: 2, label: '2 - Medium', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' }, { value: 2, labelKey: 'riskRules.riskLevels.2', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ value: 3, label: '3 - High', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' }, { value: 3, labelKey: 'riskRules.riskLevels.3', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
]; ];
const ACTIONS: { value: RiskRule['action']; label: string }[] = [ const ACTIONS: { value: RiskRule['action']; labelKey: string }[] = [
{ value: 'allow', label: 'Allow' }, { value: 'allow', labelKey: 'riskRules.actions.allow' },
{ value: 'block', label: 'Block' }, { value: 'block', labelKey: 'riskRules.actions.block' },
{ value: 'require_approval', label: 'Require Approval' }, { value: 'require_approval', labelKey: 'riskRules.actions.requireApproval' },
]; ];
const ROLES: { value: Role; label: string }[] = [ const ROLES: { value: Role; labelKey: string }[] = [
{ value: 'admin', label: 'Admin' }, { value: 'admin', labelKey: 'permissionMatrix.roles.admin' },
{ value: 'operator', label: 'Operator' }, { value: 'operator', labelKey: 'permissionMatrix.roles.operator' },
{ value: 'viewer', label: 'Viewer' }, { value: 'viewer', labelKey: 'permissionMatrix.roles.viewer' },
{ value: 'readonly', label: 'Read-only' }, { value: 'readonly', labelKey: 'permissionMatrix.roles.readonly' },
]; ];
const PERMISSIONS: { value: Permission; label: string }[] = [ const PERMISSIONS: { value: Permission; labelKey: string }[] = [
{ value: 'manage_servers', label: 'Manage Servers' }, { value: 'manage_servers', labelKey: 'permissionMatrix.permissions.manageServers' },
{ value: 'execute_commands', label: 'Execute Commands' }, { value: 'execute_commands', labelKey: 'permissionMatrix.permissions.executeCommands' },
{ value: 'view_audit', label: 'View Audit' }, { value: 'view_audit', labelKey: 'permissionMatrix.permissions.viewAudit' },
{ value: 'manage_users', label: 'Manage Users' }, { value: 'manage_users', labelKey: 'permissionMatrix.permissions.manageUsers' },
{ value: 'approve_commands', label: 'Approve Commands' }, { value: 'approve_commands', labelKey: 'permissionMatrix.permissions.approveCommands' },
]; ];
const DEFAULT_MATRIX: PermissionMatrix = { const DEFAULT_MATRIX: PermissionMatrix = {
@ -109,6 +110,7 @@ function RiskBadge({ level }: { level: RiskRule['riskLevel'] }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ActionBadge({ action }: { action: RiskRule['action'] }) { function ActionBadge({ action }: { action: RiskRule['action'] }) {
const { t } = useTranslation('security');
const styles: Record<RiskRule['action'], string> = { const styles: Record<RiskRule['action'], string> = {
allow: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', allow: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
block: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', block: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
@ -116,10 +118,10 @@ function ActionBadge({ action }: { action: RiskRule['action'] }) {
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
}; };
const labels: Record<RiskRule['action'], string> = { const labelKeys: Record<RiskRule['action'], string> = {
allow: 'Allow', allow: 'riskRules.actions.allow',
block: 'Block', block: 'riskRules.actions.block',
require_approval: 'Require Approval', require_approval: 'riskRules.actions.requireApproval',
}; };
return ( return (
@ -129,7 +131,7 @@ function ActionBadge({ action }: { action: RiskRule['action'] }) {
styles[action], styles[action],
)} )}
> >
{labels[action]} {t(labelKeys[action])}
</span> </span>
); );
} }
@ -157,6 +159,8 @@ function RuleDialog({
onChange: (field: keyof RiskRuleFormData, value: string | number) => void; onChange: (field: keyof RiskRuleFormData, value: string | number) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -170,7 +174,7 @@ function RuleDialog({
{/* pattern */} {/* pattern */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Pattern (regex) <span className="text-destructive">*</span> {t('riskRules.form.pattern')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -180,7 +184,7 @@ function RuleDialog({
'w-full px-3 py-2 rounded-md border bg-background text-sm font-mono', 'w-full px-3 py-2 rounded-md border bg-background text-sm font-mono',
errors.pattern ? 'border-destructive' : 'border-input', errors.pattern ? 'border-destructive' : 'border-input',
)} )}
placeholder="^rm\s+-rf\s+/" placeholder={t('riskRules.form.patternPlaceholder')}
/> />
{errors.pattern && ( {errors.pattern && (
<p className="text-xs text-destructive mt-1">{errors.pattern}</p> <p className="text-xs text-destructive mt-1">{errors.pattern}</p>
@ -189,7 +193,7 @@ function RuleDialog({
{/* riskLevel */} {/* riskLevel */}
<div> <div>
<label className="block text-sm font-medium mb-1">Risk Level</label> <label className="block text-sm font-medium mb-1">{t('riskRules.form.riskLevel')}</label>
<select <select
value={form.riskLevel} value={form.riskLevel}
onChange={(e) => onChange('riskLevel', parseInt(e.target.value, 10))} onChange={(e) => onChange('riskLevel', parseInt(e.target.value, 10))}
@ -197,7 +201,7 @@ function RuleDialog({
> >
{RISK_LEVELS.map((rl) => ( {RISK_LEVELS.map((rl) => (
<option key={rl.value} value={rl.value}> <option key={rl.value} value={rl.value}>
{rl.label} {t(rl.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -205,7 +209,7 @@ function RuleDialog({
{/* action */} {/* action */}
<div> <div>
<label className="block text-sm font-medium mb-1">Action</label> <label className="block text-sm font-medium mb-1">{t('riskRules.form.action')}</label>
<select <select
value={form.action} value={form.action}
onChange={(e) => onChange('action', e.target.value)} onChange={(e) => onChange('action', e.target.value)}
@ -213,7 +217,7 @@ function RuleDialog({
> >
{ACTIONS.map((a) => ( {ACTIONS.map((a) => (
<option key={a.value} value={a.value}> <option key={a.value} value={a.value}>
{a.label} {t(a.labelKey)}
</option> </option>
))} ))}
</select> </select>
@ -221,7 +225,7 @@ function RuleDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('riskRules.form.description')}</label>
<input <input
type="text" type="text"
value={form.description} value={form.description}
@ -239,7 +243,7 @@ function RuleDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -247,7 +251,7 @@ function RuleDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -272,16 +276,18 @@ function DeleteRuleDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Rule</h2> <h2 className="text-lg font-semibold mb-2">{t('riskRules.deleteRule')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete the rule with pattern{' '} {tc('confirmDelete')}{' '}
<code className="px-1 py-0.5 rounded bg-muted font-mono text-xs">{pattern}</code>? <code className="px-1 py-0.5 rounded bg-muted font-mono text-xs">{pattern}</code>
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -289,14 +295,14 @@ function DeleteRuleDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -319,11 +325,13 @@ function PermissionMatrixSection({
onToggle: (role: Role, perm: Permission) => void; onToggle: (role: Role, perm: Permission) => void;
onSave: () => void; onSave: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
return ( return (
<div className="border rounded-lg p-6"> <div className="border rounded-lg p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div> <div>
<h2 className="text-lg font-semibold">Permission Matrix</h2> <h2 className="text-lg font-semibold">{t('permissionMatrix.title')}</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Configure which roles have access to each permission. Configure which roles have access to each permission.
</p> </p>
@ -333,7 +341,7 @@ function PermissionMatrixSection({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 whitespace-nowrap"
> >
{saving ? 'Saving...' : 'Save Permissions'} {saving ? tc('saving') : t('permissionMatrix.savePermissions')}
</button> </button>
</div> </div>
@ -341,10 +349,10 @@ function PermissionMatrixSection({
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Role</th> <th className="text-left px-4 py-3 font-medium">{tc('role')}</th>
{PERMISSIONS.map((perm) => ( {PERMISSIONS.map((perm) => (
<th key={perm.value} className="text-center px-3 py-3 font-medium whitespace-nowrap"> <th key={perm.value} className="text-center px-3 py-3 font-medium whitespace-nowrap">
{perm.label} {t(perm.labelKey)}
</th> </th>
))} ))}
</tr> </tr>
@ -355,7 +363,7 @@ function PermissionMatrixSection({
key={role.value} key={role.value}
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors" className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
> >
<td className="px-4 py-3 font-medium capitalize">{role.label}</td> <td className="px-4 py-3 font-medium capitalize">{t(role.labelKey)}</td>
{PERMISSIONS.map((perm) => { {PERMISSIONS.map((perm) => {
const checked = (matrix[role.value] ?? []).includes(perm.value); const checked = (matrix[role.value] ?? []).includes(perm.value);
return ( return (
@ -383,6 +391,8 @@ function PermissionMatrixSection({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function RiskRulesPage() { export default function RiskRulesPage() {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Risk rules state ----------------------------------------------------- // Risk rules state -----------------------------------------------------
@ -538,35 +548,35 @@ export default function RiskRulesPage() {
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold">Security &amp; Risk Rules</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Configure command risk classification rules and security policies. {t('subtitle')}
</p> </p>
</div> </div>
{/* ---- Risk Rules Section ---- */} {/* ---- Risk Rules Section ---- */}
<div> <div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold">Command Risk Rules</h2> <h2 className="text-lg font-semibold">{t('riskRules.title')}</h2>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Rule {t('riskRules.addRule')}
</button> </button>
</div> </div>
{/* Error state */} {/* Error state */}
{rulesError && ( {rulesError && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load risk rules: {(rulesError as Error).message} {t('riskRules.loadError')} {(rulesError as Error).message}
</div> </div>
)} )}
{/* Loading */} {/* Loading */}
{rulesLoading && ( {rulesLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading risk rules... {t('riskRules.loading')}
</div> </div>
)} )}
@ -577,11 +587,11 @@ export default function RiskRulesPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Pattern</th> <th className="text-left px-4 py-3 font-medium">{t('riskRules.table.pattern')}</th>
<th className="text-left px-4 py-3 font-medium">Risk Level</th> <th className="text-left px-4 py-3 font-medium">{t('riskRules.table.riskLevel')}</th>
<th className="text-left px-4 py-3 font-medium">Action</th> <th className="text-left px-4 py-3 font-medium">{t('riskRules.table.action')}</th>
<th className="text-left px-4 py-3 font-medium">Description</th> <th className="text-left px-4 py-3 font-medium">{t('riskRules.table.description')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('riskRules.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -591,7 +601,7 @@ export default function RiskRulesPage() {
colSpan={5} colSpan={5}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No risk rules configured. {t('riskRules.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -620,13 +630,13 @@ export default function RiskRulesPage() {
onClick={() => openEdit(rule)} onClick={() => openEdit(rule)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(rule)} onClick={() => setDeleteTarget(rule)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -643,7 +653,7 @@ export default function RiskRulesPage() {
{/* ---- Permission Matrix Section ---- */} {/* ---- Permission Matrix Section ---- */}
{matrixLoading ? ( {matrixLoading ? (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading permissions... {tc('loading')}
</div> </div>
) : ( ) : (
<PermissionMatrixSection <PermissionMatrixSection
@ -657,7 +667,7 @@ export default function RiskRulesPage() {
{/* Add / Edit rule dialog */} {/* Add / Edit rule dialog */}
<RuleDialog <RuleDialog
open={dialogOpen} open={dialogOpen}
title={editingRule ? 'Edit Rule' : 'Add Rule'} title={editingRule ? t('riskRules.editRule') : t('riskRules.addRule')}
form={form} form={form}
errors={errors} errors={errors}
saving={isRuleSaving} saving={isRuleSaving}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -76,6 +77,7 @@ function formatDate(dateStr: string): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function SystemBadge({ isSystem }: { isSystem: boolean }) { function SystemBadge({ isSystem }: { isSystem: boolean }) {
const { t } = useTranslation('security');
if (!isSystem) return null; if (!isSystem) return null;
return ( return (
<span <span
@ -84,7 +86,7 @@ function SystemBadge({ isSystem }: { isSystem: boolean }) {
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
)} )}
> >
Built-in {t('roles.builtIn')}
</span> </span>
); );
} }
@ -112,6 +114,8 @@ function RoleDialog({
onChange: (field: keyof RoleFormData, value: string) => void; onChange: (field: keyof RoleFormData, value: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -125,7 +129,7 @@ function RoleDialog({
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('roles.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -144,7 +148,7 @@ function RoleDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('roles.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -162,7 +166,7 @@ function RoleDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -170,7 +174,7 @@ function RoleDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -197,20 +201,22 @@ function DeleteRoleDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Role</h2> <h2 className="text-lg font-semibold mb-2">{t('roles.deleteRole')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Are you sure you want to delete <strong>{roleName}</strong>? This action cannot be undone. {tc('confirmDelete')} <strong>{roleName}</strong>
</p> </p>
{userCount > 0 && ( {userCount > 0 && (
<div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive"> <div className="p-3 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-sm text-destructive">
<strong>Warning:</strong> This role is currently assigned to{' '} <strong>{tc('warning')}:</strong> This role is currently assigned to{' '}
<strong>{userCount}</strong> user{userCount !== 1 ? 's' : ''}. <strong>{userCount}</strong> user{userCount !== 1 ? 's' : ''}.
Those users will lose the permissions granted by this role. Those users will lose the permissions granted by this role.
</div> </div>
@ -222,14 +228,14 @@ function DeleteRoleDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -254,6 +260,7 @@ function PermissionsPanel({
saving: boolean; saving: boolean;
onToggle: (roleId: string, permissionId: string, checked: boolean) => void; onToggle: (roleId: string, permissionId: string, checked: boolean) => void;
}) { }) {
const { t } = useTranslation('security');
const assignedIds = useMemo( const assignedIds = useMemo(
() => new Set(rolePermissions.map((p) => p.id)), () => new Set(rolePermissions.map((p) => p.id)),
[rolePermissions], [rolePermissions],
@ -271,7 +278,7 @@ function PermissionsPanel({
return ( return (
<div className="px-4 py-4 bg-muted/20"> <div className="px-4 py-4 bg-muted/20">
<h3 className="text-sm font-semibold mb-3">Assigned Permissions</h3> <h3 className="text-sm font-semibold mb-3">{t('roles.assignedPermissions')}</h3>
{Object.keys(grouped).length === 0 ? ( {Object.keys(grouped).length === 0 ? (
<p className="text-sm text-muted-foreground">No permissions available.</p> <p className="text-sm text-muted-foreground">No permissions available.</p>
) : ( ) : (
@ -313,7 +320,7 @@ function PermissionsPanel({
</div> </div>
)} )}
{saving && ( {saving && (
<p className="text-xs text-muted-foreground mt-2">Saving permissions...</p> <p className="text-xs text-muted-foreground mt-2">{t('roles.savingPermissions')}</p>
)} )}
</div> </div>
); );
@ -324,6 +331,8 @@ function PermissionsPanel({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function RolesPage() { export default function RolesPage() {
const { t } = useTranslation('security');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -492,30 +501,30 @@ export default function RolesPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Roles</h1> <h1 className="text-2xl font-bold">{t('roles.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage roles and their associated permissions {t('roles.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Role {t('roles.addRole')}
</button> </button>
</div> </div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load roles: {(error as Error).message} {t('roles.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading roles... {t('roles.loading')}
</div> </div>
)} )}
@ -526,13 +535,13 @@ export default function RolesPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('roles.table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Description</th> <th className="text-left px-4 py-3 font-medium">{t('roles.table.description')}</th>
<th className="text-left px-4 py-3 font-medium">Type</th> <th className="text-left px-4 py-3 font-medium">{t('roles.table.type')}</th>
<th className="text-right px-4 py-3 font-medium">Permissions</th> <th className="text-right px-4 py-3 font-medium">{t('roles.table.permissions')}</th>
<th className="text-right px-4 py-3 font-medium">Users</th> <th className="text-right px-4 py-3 font-medium">{t('roles.table.users')}</th>
<th className="text-left px-4 py-3 font-medium">Created</th> <th className="text-left px-4 py-3 font-medium">{t('roles.table.created')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('roles.table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -542,7 +551,7 @@ export default function RolesPage() {
colSpan={7} colSpan={7}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No roles found. Add one to get started. {t('roles.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -562,7 +571,7 @@ export default function RolesPage() {
<SystemBadge isSystem={role.isSystem} /> <SystemBadge isSystem={role.isSystem} />
{!role.isSystem && ( {!role.isSystem && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300">
Custom {t('roles.custom')}
</span> </span>
)} )}
</td> </td>
@ -581,20 +590,20 @@ export default function RolesPage() {
onClick={() => toggleExpand(role.id)} onClick={() => toggleExpand(role.id)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
{expandedRoleId === role.id ? 'Hide Perms' : 'Permissions'} {expandedRoleId === role.id ? t('roles.hidePerms') : t('roles.showPerms')}
</button> </button>
<button <button
onClick={() => openEdit(role)} onClick={() => openEdit(role)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
{!role.isSystem && ( {!role.isSystem && (
<button <button
onClick={() => setDeleteTarget(role)} onClick={() => setDeleteTarget(role)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
)} )}
</div> </div>
@ -607,7 +616,7 @@ export default function RolesPage() {
<td colSpan={7}> <td colSpan={7}>
{rolePermsLoading ? ( {rolePermsLoading ? (
<div className="px-4 py-6 text-sm text-muted-foreground text-center"> <div className="px-4 py-6 text-sm text-muted-foreground text-center">
Loading permissions... {tc('loading')}
</div> </div>
) : ( ) : (
<PermissionsPanel <PermissionsPanel
@ -633,7 +642,7 @@ export default function RolesPage() {
{/* Add / Edit role dialog */} {/* Add / Edit role dialog */}
<RoleDialog <RoleDialog
open={dialogOpen} open={dialogOpen}
title={editingRole ? 'Edit Role' : 'Add Role'} title={editingRole ? t('roles.editRole') : t('roles.addRole')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -2,6 +2,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -157,16 +158,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Server</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteServer')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{hostname}</strong>? This action cannot be {t('dialog.deleteConfirm')} <strong>{hostname}</strong>? {t('dialog.deleteWarning')}
undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -174,14 +177,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -235,6 +238,8 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ServerDetailPage() { export default function ServerDetailPage() {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -354,8 +359,8 @@ export default function ServerDetailPage() {
const validate = useCallback((): boolean => { const validate = useCallback((): boolean => {
const next: Partial<Record<keyof ServerFormData, string>> = {}; const next: Partial<Record<keyof ServerFormData, string>> = {};
if (!form.hostname.trim()) next.hostname = 'Hostname is required'; if (!form.hostname.trim()) next.hostname = t('validation.hostnameRequired');
if (!form.host.trim()) next.host = 'Host is required'; if (!form.host.trim()) next.host = t('validation.hostRequired');
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form]);
@ -401,10 +406,10 @@ export default function ServerDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Servers {t('detail.backToServers')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading server details... {t('detail.loading')}
</div> </div>
</div> </div>
); );
@ -432,10 +437,10 @@ export default function ServerDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Servers {t('detail.backToServers')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load server: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -466,7 +471,7 @@ export default function ServerDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Servers {t('detail.backToServers')}
</button> </button>
{/* Page header */} {/* Page header */}
@ -482,13 +487,13 @@ export default function ServerDetailPage() {
{/* Server Information Card */} {/* Server Information Card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Server Information</h2> <h2 className="text-lg font-semibold">{t('detail.serverInformation')}</h2>
{!isEditing && ( {!isEditing && (
<button <button
onClick={startEditing} onClick={startEditing}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -499,7 +504,7 @@ export default function ServerDetailPage() {
{/* hostname */} {/* hostname */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Hostname <span className="text-destructive">*</span> {t('form.hostname')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -519,7 +524,7 @@ export default function ServerDetailPage() {
{/* host */} {/* host */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Host (IP) <span className="text-destructive">*</span> {t('form.hostIp')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -538,7 +543,7 @@ export default function ServerDetailPage() {
{/* sshPort */} {/* sshPort */}
<div> <div>
<label className="block text-sm font-medium mb-1">SSH Port</label> <label className="block text-sm font-medium mb-1">{t('form.sshPort')}</label>
<input <input
type="number" type="number"
value={form.sshPort} value={form.sshPort}
@ -553,7 +558,7 @@ export default function ServerDetailPage() {
{/* environment */} {/* environment */}
<div> <div>
<label className="block text-sm font-medium mb-1">Environment</label> <label className="block text-sm font-medium mb-1">{t('form.environment')}</label>
<select <select
value={form.environment} value={form.environment}
onChange={(e) => handleChange('environment', e.target.value)} onChange={(e) => handleChange('environment', e.target.value)}
@ -567,7 +572,7 @@ export default function ServerDetailPage() {
{/* role */} {/* role */}
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<input <input
type="text" type="text"
value={form.role} value={form.role}
@ -579,32 +584,32 @@ export default function ServerDetailPage() {
{/* tags */} {/* tags */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tags</label> <label className="block text-sm font-medium mb-1">{t('form.tags')}</label>
<input <input
type="text" type="text"
value={form.tags} value={form.tags}
onChange={(e) => handleChange('tags', e.target.value)} onChange={(e) => handleChange('tags', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="linux, ubuntu, docker (comma-separated)" placeholder={t('form.tagsPlaceholder')}
/> />
</div> </div>
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => handleChange('description', e.target.value)} onChange={(e) => handleChange('description', e.target.value)}
rows={3} rows={3}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
placeholder="Optional description..." placeholder={t('form.descriptionPlaceholder')}
/> />
</div> </div>
{/* Update error */} {/* Update error */}
{updateMutation.isError && ( {updateMutation.isError && (
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to update server: {(updateMutation.error as Error).message} {t('detail.loadError')} {(updateMutation.error as Error).message}
</div> </div>
)} )}
@ -616,7 +621,7 @@ export default function ServerDetailPage() {
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -624,30 +629,30 @@ export default function ServerDetailPage() {
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending ? tc('saving') : t('detail.saveChanges')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
/* ---------- Read-only info display ---------- */ /* ---------- Read-only info display ---------- */
<dl className="divide-y"> <dl className="divide-y">
<InfoRow label="Hostname" value={server.hostname} /> <InfoRow label={t('form.hostname')} value={server.hostname} />
<InfoRow <InfoRow
label="Host IP" label={t('form.hostIp')}
value={ value={
<span className="font-mono text-xs">{server.host}</span> <span className="font-mono text-xs">{server.host}</span>
} }
/> />
<InfoRow label="SSH Port" value={String(server.sshPort)} /> <InfoRow label={t('form.sshPort')} value={String(server.sshPort)} />
<InfoRow <InfoRow
label="Environment" label={t('form.environment')}
value={ value={
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize"> <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">
{server.environment} {server.environment}
</span> </span>
} }
/> />
<InfoRow label="Role" value={server.role} /> <InfoRow label={t('form.role')} value={server.role} />
<InfoRow label="OS" value={server.os} /> <InfoRow label="OS" value={server.os} />
{server.cpuCores != null && ( {server.cpuCores != null && (
<InfoRow label="CPU Cores" value={String(server.cpuCores)} /> <InfoRow label="CPU Cores" value={String(server.cpuCores)} />
@ -660,7 +665,7 @@ export default function ServerDetailPage() {
<InfoRow label="Cloud Provider" value={server.cloudProvider} /> <InfoRow label="Cloud Provider" value={server.cloudProvider} />
)} )}
<InfoRow <InfoRow
label="Tags" label={t('form.tags')}
value={ value={
server.tags && server.tags.length > 0 ? ( server.tags && server.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -676,9 +681,9 @@ export default function ServerDetailPage() {
) : null ) : null
} }
/> />
<InfoRow label="Description" value={server.description} /> <InfoRow label={t('form.description')} value={server.description} />
<InfoRow label="Created" value={formatDate(server.createdAt)} /> <InfoRow label={tc('created')} value={formatDate(server.createdAt)} />
<InfoRow label="Updated" value={formatDate(server.updatedAt)} /> <InfoRow label={tc('updated')} value={formatDate(server.updatedAt)} />
</dl> </dl>
)} )}
</div> </div>
@ -686,13 +691,13 @@ export default function ServerDetailPage() {
{/* Recent Health Checks */} {/* Recent Health Checks */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Recent Health Checks</h2> <h2 className="text-lg font-semibold">{t('detail.recentHealthChecks')}</h2>
<button <button
onClick={() => runHealthCheckMutation.mutate()} onClick={() => runHealthCheckMutation.mutate()}
disabled={runHealthCheckMutation.isPending} disabled={runHealthCheckMutation.isPending}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50"
> >
{runHealthCheckMutation.isPending ? 'Running...' : 'Run Now'} {runHealthCheckMutation.isPending ? tc('running') : t('detail.runHealthCheck')}
</button> </button>
</div> </div>
@ -710,17 +715,17 @@ export default function ServerDetailPage() {
{healthChecks.length === 0 ? ( {healthChecks.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No health checks recorded yet. {t('detail.noHealthChecks')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Status</th> <th className="text-left px-3 py-2 font-medium">{t('detail.healthCheckTable.status')}</th>
<th className="text-left px-3 py-2 font-medium">Latency</th> <th className="text-left px-3 py-2 font-medium">{t('detail.healthCheckTable.latency')}</th>
<th className="text-left px-3 py-2 font-medium">Message</th> <th className="text-left px-3 py-2 font-medium">{t('detail.healthCheckTable.message')}</th>
<th className="text-right px-3 py-2 font-medium">Time</th> <th className="text-right px-3 py-2 font-medium">{t('detail.healthCheckTable.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -751,21 +756,21 @@ export default function ServerDetailPage() {
{/* Recent Commands */} {/* Recent Commands */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Recent Commands</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.recentCommands')}</h2>
{recentCommands.length === 0 ? ( {recentCommands.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No commands executed on this server yet. {t('detail.noCommands')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Command</th> <th className="text-left px-3 py-2 font-medium">{t('detail.commandsTable.command')}</th>
<th className="text-left px-3 py-2 font-medium">Exit Code</th> <th className="text-left px-3 py-2 font-medium">{t('detail.commandsTable.exitCode')}</th>
<th className="text-left px-3 py-2 font-medium">Risk</th> <th className="text-left px-3 py-2 font-medium">{t('detail.commandsTable.risk')}</th>
<th className="text-right px-3 py-2 font-medium">Time</th> <th className="text-right px-3 py-2 font-medium">{t('detail.commandsTable.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -812,7 +817,7 @@ export default function ServerDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Quick Actions */} {/* Quick Actions */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.quickActions')}</h2>
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={() => router.push(`/terminal?serverId=${server.id}`)} onClick={() => router.push(`/terminal?serverId=${server.id}`)}
@ -832,7 +837,7 @@ export default function ServerDetailPage() {
<polyline points="4 17 10 11 4 5" /> <polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" /> <line x1="12" x2="20" y1="19" y2="19" />
</svg> </svg>
Open Terminal {t('detail.openTerminal')}
</button> </button>
<button <button
onClick={() => runHealthCheckMutation.mutate()} onClick={() => runHealthCheckMutation.mutate()}
@ -852,7 +857,7 @@ export default function ServerDetailPage() {
> >
<path d="M22 12h-4l-3 9L9 3l-3 9H2" /> <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg> </svg>
{runHealthCheckMutation.isPending ? 'Running...' : 'Run Health Check'} {runHealthCheckMutation.isPending ? tc('running') : t('detail.runHealthCheck')}
</button> </button>
<button <button
onClick={startEditing} onClick={startEditing}
@ -872,7 +877,7 @@ export default function ServerDetailPage() {
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" /> <path d="m15 5 4 4" />
</svg> </svg>
Edit Server {t('detail.editServer')}
</button> </button>
<button <button
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
@ -895,14 +900,14 @@ export default function ServerDetailPage() {
<line x1="10" x2="10" y1="11" y2="17" /> <line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" /> <line x1="14" x2="14" y1="11" y2="17" />
</svg> </svg>
Delete Server {t('detail.deleteServer')}
</button> </button>
</div> </div>
</div> </div>
{/* Connection Info */} {/* Connection Info */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Connection Info</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.connectionInfo')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="text-sm text-muted-foreground mb-1">SSH Connection</p> <p className="text-sm text-muted-foreground mb-1">SSH Connection</p>
@ -942,7 +947,7 @@ export default function ServerDetailPage() {
{/* Tags */} {/* Tags */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Tags</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.tags')}</h2>
{server.tags && server.tags.length > 0 ? ( {server.tags && server.tags.length > 0 ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{server.tags.map((tag) => ( {server.tags.map((tag) => (
@ -955,7 +960,7 @@ export default function ServerDetailPage() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No tags assigned.</p> <p className="text-sm text-muted-foreground">{t('detail.noTags')}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -95,29 +96,31 @@ function HealthSummary({
}: { }: {
summary: Cluster['healthySummary']; summary: Cluster['healthySummary'];
}) { }) {
const parts: { count: number; label: string; color: string }[] = []; const { t } = useTranslation('servers');
const parts: { count: number; labelKey: string; color: string }[] = [];
if (summary.online > 0) { if (summary.online > 0) {
parts.push({ count: summary.online, label: 'online', color: 'bg-green-500' }); parts.push({ count: summary.online, labelKey: 'clusters.health.online', color: 'bg-green-500' });
} }
if (summary.offline > 0) { if (summary.offline > 0) {
parts.push({ count: summary.offline, label: 'offline', color: 'bg-red-500' }); parts.push({ count: summary.offline, labelKey: 'clusters.health.offline', color: 'bg-red-500' });
} }
if (summary.maintenance > 0) { if (summary.maintenance > 0) {
parts.push({ count: summary.maintenance, label: 'maintenance', color: 'bg-yellow-500' }); parts.push({ count: summary.maintenance, labelKey: 'clusters.health.maintenance', color: 'bg-yellow-500' });
} }
if (parts.length === 0) { if (parts.length === 0) {
return ( return (
<span className="text-xs text-muted-foreground">No servers</span> <span className="text-xs text-muted-foreground">{t('clusters.health.noServers')}</span>
); );
} }
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{parts.map((part) => ( {parts.map((part) => (
<span key={part.label} className="inline-flex items-center gap-1 text-xs text-muted-foreground"> <span key={part.labelKey} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className={cn('w-2 h-2 rounded-full', part.color)} /> <span className={cn('w-2 h-2 rounded-full', part.color)} />
{part.count} {part.label} {part.count} {t(part.labelKey)}
</span> </span>
))} ))}
</div> </div>
@ -153,6 +156,9 @@ function ClusterDialog({
onToggleServer: (serverId: string) => void; onToggleServer: (serverId: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -168,7 +174,7 @@ function ClusterDialog({
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {t('clusters.form.name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -187,7 +193,7 @@ function ClusterDialog({
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('clusters.form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
@ -199,7 +205,7 @@ function ClusterDialog({
{/* environment */} {/* environment */}
<div> <div>
<label className="block text-sm font-medium mb-1">Environment</label> <label className="block text-sm font-medium mb-1">{t('clusters.form.environment')}</label>
<select <select
value={form.environment} value={form.environment}
onChange={(e) => onChange('environment', e.target.value)} onChange={(e) => onChange('environment', e.target.value)}
@ -213,7 +219,7 @@ function ClusterDialog({
{/* tags */} {/* tags */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tags</label> <label className="block text-sm font-medium mb-1">{t('clusters.form.tags')}</label>
<input <input
type="text" type="text"
value={form.tags} value={form.tags}
@ -225,14 +231,14 @@ function ClusterDialog({
{/* server selection */} {/* server selection */}
<div> <div>
<label className="block text-sm font-medium mb-1">Servers</label> <label className="block text-sm font-medium mb-1">{t('clusters.form.servers')}</label>
{serversLoading ? ( {serversLoading ? (
<div className="text-xs text-muted-foreground py-4 text-center"> <div className="text-xs text-muted-foreground py-4 text-center">
Loading servers... {tc('loading')}
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center"> <div className="text-xs text-muted-foreground py-4 text-center">
No servers available. {tc('noData')}
</div> </div>
) : ( ) : (
<div className="max-h-[200px] overflow-y-auto border border-input rounded-md"> <div className="max-h-[200px] overflow-y-auto border border-input rounded-md">
@ -283,7 +289,7 @@ function ClusterDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -291,7 +297,7 @@ function ClusterDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -316,16 +322,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Cluster</h2> <h2 className="text-lg font-semibold mb-2">{t('clusters.deleteCluster')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{clusterName}</strong>? This action cannot be {t('dialog.deleteConfirm')} <strong>{clusterName}</strong>? {t('dialog.deleteWarning')}
undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -333,14 +341,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -353,6 +361,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ClustersPage() { export default function ClustersPage() {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -410,7 +420,7 @@ export default function ClustersPage() {
// Helpers -------------------------------------------------------------- // Helpers --------------------------------------------------------------
const validate = useCallback((): boolean => { const validate = useCallback((): boolean => {
const next: Partial<Record<keyof ClusterFormData, string>> = {}; const next: Partial<Record<keyof ClusterFormData, string>> = {};
if (!form.name.trim()) next.name = 'Name is required'; if (!form.name.trim()) next.name = t('validation.hostnameRequired');
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form]);
@ -494,38 +504,38 @@ export default function ClustersPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Clusters</h1> <h1 className="text-2xl font-bold">{t('clusters.title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Organize servers into logical groups {t('clusters.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Cluster {t('clusters.addCluster')}
</button> </button>
</div> </div>
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load clusters: {(error as Error).message} {t('clusters.loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading clusters... {t('clusters.loading')}
</div> </div>
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !error && clusters.length === 0 && ( {!isLoading && !error && clusters.length === 0 && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<p className="text-sm">No clusters found.</p> <p className="text-sm">{t('clusters.empty')}</p>
<p className="text-xs mt-1">Create your first cluster to organize servers into logical groups.</p> <p className="text-xs mt-1">{t('clusters.subtitle')}</p>
</div> </div>
)} )}
@ -582,13 +592,13 @@ export default function ClustersPage() {
onClick={() => openEdit(cluster)} onClick={() => openEdit(cluster)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(cluster)} onClick={() => setDeleteTarget(cluster)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -599,7 +609,7 @@ export default function ClustersPage() {
{/* Add / Edit dialog */} {/* Add / Edit dialog */}
<ClusterDialog <ClusterDialog
open={dialogOpen} open={dialogOpen}
title={editingCluster ? 'Edit Cluster' : 'Add Cluster'} title={editingCluster ? t('clusters.editCluster') : t('clusters.addCluster')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -2,6 +2,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -43,12 +44,7 @@ type EnvironmentFilter = 'all' | 'dev' | 'staging' | 'prod';
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ENVIRONMENTS: { label: string; value: EnvironmentFilter }[] = [ const ENVIRONMENT_VALUES: EnvironmentFilter[] = ['all', 'dev', 'staging', 'prod'];
{ label: 'All', value: 'all' },
{ label: 'Dev', value: 'dev' },
{ label: 'Staging', value: 'staging' },
{ label: 'Prod', value: 'prod' },
];
const EMPTY_FORM: ServerFormData = { const EMPTY_FORM: ServerFormData = {
hostname: '', hostname: '',
@ -106,6 +102,9 @@ function ServerDialog({
onChange: (field: keyof ServerFormData, value: string | number) => void; onChange: (field: keyof ServerFormData, value: string | number) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
@ -121,7 +120,7 @@ function ServerDialog({
{/* hostname */} {/* hostname */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Hostname <span className="text-destructive">*</span> {t('form.hostname')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -141,7 +140,7 @@ function ServerDialog({
{/* host */} {/* host */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Host (IP) <span className="text-destructive">*</span> {t('form.hostIp')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -160,7 +159,7 @@ function ServerDialog({
{/* sshPort */} {/* sshPort */}
<div> <div>
<label className="block text-sm font-medium mb-1">SSH Port</label> <label className="block text-sm font-medium mb-1">{t('form.sshPort')}</label>
<input <input
type="number" type="number"
value={form.sshPort} value={form.sshPort}
@ -173,7 +172,7 @@ function ServerDialog({
{/* environment */} {/* environment */}
<div> <div>
<label className="block text-sm font-medium mb-1">Environment</label> <label className="block text-sm font-medium mb-1">{t('form.environment')}</label>
<select <select
value={form.environment} value={form.environment}
onChange={(e) => onChange('environment', e.target.value)} onChange={(e) => onChange('environment', e.target.value)}
@ -187,7 +186,7 @@ function ServerDialog({
{/* role */} {/* role */}
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<input <input
type="text" type="text"
value={form.role} value={form.role}
@ -199,25 +198,25 @@ function ServerDialog({
{/* tags */} {/* tags */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tags</label> <label className="block text-sm font-medium mb-1">{t('form.tags')}</label>
<input <input
type="text" type="text"
value={form.tags} value={form.tags}
onChange={(e) => onChange('tags', e.target.value)} onChange={(e) => onChange('tags', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="linux, ubuntu, docker (comma-separated)" placeholder={t('form.tagsPlaceholder')}
/> />
</div> </div>
{/* description */} {/* description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{t('form.description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => onChange('description', e.target.value)} onChange={(e) => onChange('description', e.target.value)}
rows={3} rows={3}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
placeholder="Optional description..." placeholder={t('form.descriptionPlaceholder')}
/> />
</div> </div>
</div> </div>
@ -230,7 +229,7 @@ function ServerDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -238,7 +237,7 @@ function ServerDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -263,15 +262,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Server</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteServer')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{hostname}</strong>? This action cannot be undone. {t('dialog.deleteConfirm')} <strong>{hostname}</strong>? {t('dialog.deleteWarning')}
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -279,14 +281,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -299,6 +301,8 @@ function DeleteDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function ServersPage() { export default function ServersPage() {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
@ -353,8 +357,8 @@ export default function ServersPage() {
// Helpers -------------------------------------------------------------- // Helpers --------------------------------------------------------------
const validate = useCallback((): boolean => { const validate = useCallback((): boolean => {
const next: Partial<Record<keyof ServerFormData, string>> = {}; const next: Partial<Record<keyof ServerFormData, string>> = {};
if (!form.hostname.trim()) next.hostname = 'Hostname is required'; if (!form.hostname.trim()) next.hostname = t('validation.hostnameRequired');
if (!form.host.trim()) next.host = 'Host is required'; if (!form.host.trim()) next.host = t('validation.hostRequired');
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form]);
@ -430,33 +434,33 @@ export default function ServersPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Servers</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage server inventory, clusters, and SSH configurations. {t('subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add Server {t('addServer')}
</button> </button>
</div> </div>
{/* Environment filter tabs */} {/* Environment filter tabs */}
<div className="flex gap-1 mb-6 p-1 bg-muted rounded-lg w-fit"> <div className="flex gap-1 mb-6 p-1 bg-muted rounded-lg w-fit">
{ENVIRONMENTS.map((env) => ( {ENVIRONMENT_VALUES.map((env) => (
<button <button
key={env.value} key={env}
onClick={() => setEnvFilter(env.value)} onClick={() => setEnvFilter(env)}
className={cn( className={cn(
'px-4 py-1.5 text-sm rounded-md font-medium transition-colors', 'px-4 py-1.5 text-sm rounded-md font-medium transition-colors',
envFilter === env.value envFilter === env
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{env.label} {t(`filters.${env}`)}
</button> </button>
))} ))}
</div> </div>
@ -464,14 +468,14 @@ export default function ServersPage() {
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load servers: {(error as Error).message} {t('loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading servers... {t('loading')}
</div> </div>
)} )}
@ -482,12 +486,12 @@ export default function ServersPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Hostname</th> <th className="text-left px-4 py-3 font-medium">{t('table.hostname')}</th>
<th className="text-left px-4 py-3 font-medium">Host</th> <th className="text-left px-4 py-3 font-medium">{t('table.host')}</th>
<th className="text-left px-4 py-3 font-medium">Environment</th> <th className="text-left px-4 py-3 font-medium">{t('table.environment')}</th>
<th className="text-left px-4 py-3 font-medium">Role</th> <th className="text-left px-4 py-3 font-medium">{t('table.role')}</th>
<th className="text-left px-4 py-3 font-medium">Status</th> <th className="text-left px-4 py-3 font-medium">{t('table.status')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -497,7 +501,7 @@ export default function ServersPage() {
colSpan={6} colSpan={6}
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
No servers found. {t('empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -525,13 +529,13 @@ export default function ServersPage() {
onClick={() => openEdit(server)} onClick={() => openEdit(server)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(server)} onClick={() => setDeleteTarget(server)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -547,7 +551,7 @@ export default function ServersPage() {
{/* Add / Edit dialog */} {/* Add / Edit dialog */}
<ServerDialog <ServerDialog
open={dialogOpen} open={dialogOpen}
title={editingServer ? 'Edit Server' : 'Add Server'} title={editingServer ? t('editServer') : t('addServer')}
form={form} form={form}
errors={errors} errors={errors}
saving={isSaving} saving={isSaving}

View File

@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -61,11 +62,6 @@ const ENGINE_STYLES: Record<SessionDetail['engineType'], string> = {
claude_api: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', claude_api: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
}; };
const ENGINE_LABELS: Record<SessionDetail['engineType'], string> = {
claude_code_cli: 'Claude Code CLI',
claude_api: 'Claude API',
};
const EVENT_TYPE_STYLES: Record<SessionEvent['type'], string> = { const EVENT_TYPE_STYLES: Record<SessionEvent['type'], string> = {
assistant: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', assistant: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
tool_use: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', tool_use: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
@ -99,6 +95,12 @@ function StatusBadge({ status }: { status: SessionDetail['status'] }) {
} }
function EngineBadge({ engine }: { engine: SessionDetail['engineType'] }) { function EngineBadge({ engine }: { engine: SessionDetail['engineType'] }) {
const { t } = useTranslation('sessions');
const ENGINE_LABELS: Record<SessionDetail['engineType'], string> = {
claude_code_cli: t('detail.engines.claudeCli'),
claude_api: t('detail.engines.claudeApi'),
};
return ( return (
<span <span
className={cn( className={cn(
@ -236,6 +238,8 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function SessionDetailPage() { export default function SessionDetailPage() {
const { t } = useTranslation('sessions');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -318,10 +322,10 @@ export default function SessionDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Sessions {t('detail.backToSessions')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading session details... {t('detail.loading')}
</div> </div>
</div> </div>
); );
@ -349,10 +353,10 @@ export default function SessionDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Sessions {t('detail.backToSessions')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load session: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -381,13 +385,13 @@ export default function SessionDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Sessions {t('detail.backToSessions')}
</button> </button>
{/* Page header */} {/* Page header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Session</h1> <h1 className="text-2xl font-bold">{t('detail.title')}</h1>
<StatusBadge status={session.status} /> <StatusBadge status={session.status} />
<EngineBadge engine={session.engineType} /> <EngineBadge engine={session.engineType} />
</div> </div>
@ -402,33 +406,33 @@ export default function SessionDetailPage() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Session Information Card */} {/* Session Information Card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Session Information</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.sessionInformation')}</h2>
<dl className="divide-y"> <dl className="divide-y">
<InfoRow <InfoRow
label="Session ID" label={t('detail.info.sessionId')}
value={ value={
<code className="font-mono text-xs">{session.id}</code> <code className="font-mono text-xs">{session.id}</code>
} }
/> />
<InfoRow <InfoRow
label="Status" label={t('detail.info.status')}
value={<StatusBadge status={session.status} />} value={<StatusBadge status={session.status} />}
/> />
<InfoRow <InfoRow
label="Engine" label={t('detail.info.engine')}
value={<EngineBadge engine={session.engineType} />} value={<EngineBadge engine={session.engineType} />}
/> />
<InfoRow label="Started At" value={formatDate(session.startedAt)} /> <InfoRow label={t('detail.info.startedAt')} value={formatDate(session.startedAt)} />
<InfoRow <InfoRow
label="Ended At" label={t('detail.info.endedAt')}
value={session.endedAt ? formatDate(session.endedAt) : 'In progress...'} value={session.endedAt ? formatDate(session.endedAt) : t('detail.inProgressDuration')}
/> />
<InfoRow <InfoRow
label="Duration" label={t('detail.info.duration')}
value={formatDuration(session.startedAt, session.endedAt)} value={formatDuration(session.startedAt, session.endedAt)}
/> />
<InfoRow <InfoRow
label="Token Count" label={t('detail.info.tokenCount')}
value={ value={
<span className="tabular-nums"> <span className="tabular-nums">
{session.tokenCount.toLocaleString()} {session.tokenCount.toLocaleString()}
@ -436,7 +440,7 @@ export default function SessionDetailPage() {
} }
/> />
<InfoRow <InfoRow
label="Total Cost" label={t('detail.info.totalCost')}
value={ value={
<span className="tabular-nums"> <span className="tabular-nums">
{formatCurrency(session.totalCostUsd)} {formatCurrency(session.totalCostUsd)}
@ -444,7 +448,7 @@ export default function SessionDetailPage() {
} }
/> />
{session.taskDescription && ( {session.taskDescription && (
<InfoRow label="Task" value={session.taskDescription} /> <InfoRow label={t('detail.info.task')} value={session.taskDescription} />
)} )}
</dl> </dl>
</div> </div>
@ -453,7 +457,7 @@ export default function SessionDetailPage() {
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
Event Stream {t('detail.eventStream')}
<span className="text-sm font-normal text-muted-foreground ml-2"> <span className="text-sm font-normal text-muted-foreground ml-2">
({events.length} events) ({events.length} events)
</span> </span>
@ -461,13 +465,13 @@ export default function SessionDetailPage() {
<ToggleSwitch <ToggleSwitch
checked={autoScroll} checked={autoScroll}
onChange={setAutoScroll} onChange={setAutoScroll}
label="Auto-scroll" label={t('detail.autoScroll')}
/> />
</div> </div>
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center"> <p className="text-sm text-muted-foreground py-8 text-center">
No events recorded yet. {t('detail.noEvents')}
</p> </p>
) : ( ) : (
<div className="max-h-[600px] overflow-y-auto space-y-1"> <div className="max-h-[600px] overflow-y-auto space-y-1">
@ -528,7 +532,7 @@ export default function SessionDetailPage() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span> </span>
Live -- streaming events... {t('detail.liveStreaming')}
</div> </div>
)} )}
</div> </div>
@ -538,36 +542,36 @@ export default function SessionDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Session Stats */} {/* Session Stats */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Statistics</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.statistics')}</h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span> <span className="text-sm text-muted-foreground">{t('detail.info.status')}</span>
<StatusBadge status={session.status} /> <StatusBadge status={session.status} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Engine</span> <span className="text-sm text-muted-foreground">{t('detail.info.engine')}</span>
<EngineBadge engine={session.engineType} /> <EngineBadge engine={session.engineType} />
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Duration</span> <span className="text-sm text-muted-foreground">{t('detail.info.duration')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{formatDuration(session.startedAt, session.endedAt)} {formatDuration(session.startedAt, session.endedAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tokens</span> <span className="text-sm text-muted-foreground">{t('detail.info.tokenCount')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{session.tokenCount.toLocaleString()} {session.tokenCount.toLocaleString()}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cost</span> <span className="text-sm text-muted-foreground">{t('detail.info.totalCost')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{formatCurrency(session.totalCostUsd)} {formatCurrency(session.totalCostUsd)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Events</span> <span className="text-sm text-muted-foreground">{t('detail.eventStream')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{events.length} {events.length}
</span> </span>
@ -577,10 +581,10 @@ export default function SessionDetailPage() {
{/* Tasks */} {/* Tasks */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Tasks</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.tasks')}</h2>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4"> <p className="text-sm text-muted-foreground text-center py-4">
No tasks in this session. {t('detail.noTasks')}
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -605,7 +609,7 @@ export default function SessionDetailPage() {
{/* Server Targets */} {/* Server Targets */}
{session.serverTargets && session.serverTargets.length > 0 && ( {session.serverTargets && session.serverTargets.length > 0 && (
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Server Targets</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.serverTargets')}</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{session.serverTargets.map((server) => ( {session.serverTargets.map((server) => (
<span <span
@ -621,18 +625,18 @@ export default function SessionDetailPage() {
{/* Timestamps */} {/* Timestamps */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Timestamps</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.timestamps')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Started</span> <span className="text-sm text-muted-foreground">{t('detail.info.startedAt')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(session.startedAt)} {formatDate(session.startedAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ended</span> <span className="text-sm text-muted-foreground">{t('detail.info.endedAt')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{session.endedAt ? formatDate(session.endedAt) : 'In progress'} {session.endedAt ? formatDate(session.endedAt) : t('detail.inProgressStatus')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -2,9 +2,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useLocaleStore, type SupportedLocale } from '@/stores/zustand/locale-store';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
@ -48,13 +50,7 @@ interface AccountInfo {
type SectionId = 'general' | 'notifications' | 'apikeys' | 'theme' | 'account'; type SectionId = 'general' | 'notifications' | 'apikeys' | 'theme' | 'account';
const SECTIONS: { id: SectionId; label: string }[] = [ const SECTION_IDS: SectionId[] = ['general', 'notifications', 'apikeys', 'theme', 'account'];
{ id: 'general', label: 'General' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'apikeys', label: 'API Keys' },
{ id: 'theme', label: 'Theme' },
{ id: 'account', label: 'Account' },
];
const TIMEZONES = [ const TIMEZONES = [
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
@ -63,15 +59,13 @@ const TIMEZONES = [
'Australia/Sydney', 'Australia/Sydney',
]; ];
const LANGUAGES = [ const UI_LANGUAGES: { value: SupportedLocale; labelKey: string }[] = [
{ value: 'en', label: 'English' }, { value: 'en', labelKey: 'languages.en' },
{ value: 'ko', label: 'Korean' }, { value: 'zh', labelKey: 'languages.zh' },
{ value: 'ja', label: 'Japanese' },
{ value: 'zh', label: 'Chinese' },
{ value: 'de', label: 'German' },
{ value: 'fr', label: 'French' },
]; ];
const PLATFORM_LANGUAGES = ['en', 'ko', 'ja', 'zh', 'de', 'fr'];
const ESCALATION_POLICIES = ['immediate', 'after-5-min', 'after-15-min', 'after-30-min', 'manual']; const ESCALATION_POLICIES = ['immediate', 'after-5-min', 'after-15-min', 'after-30-min', 'manual'];
const COLOR_PRESETS = [ const COLOR_PRESETS = [
@ -84,30 +78,31 @@ const COLOR_PRESETS = [
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation('settings');
const [activeSection, setActiveSection] = useState<SectionId>('general'); const [activeSection, setActiveSection] = useState<SectionId>('general');
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-1">Settings</h1> <h1 className="text-2xl font-bold mb-1">{t('title')}</h1>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Application preferences and configurations. {t('subtitle')}
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
{/* ---- Section Nav ---- */} {/* ---- Section Nav ---- */}
<nav className="w-48 shrink-0"> <nav className="w-48 shrink-0">
<ul className="space-y-1"> <ul className="space-y-1">
{SECTIONS.map((s) => ( {SECTION_IDS.map((id) => (
<li key={s.id}> <li key={id}>
<button <button
onClick={() => setActiveSection(s.id)} onClick={() => setActiveSection(id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors ${
activeSection === s.id activeSection === id
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground' : 'hover:bg-muted text-muted-foreground'
}`} }`}
> >
{s.label} {t(`sections.${id}`)}
</button> </button>
</li> </li>
))} ))}
@ -132,7 +127,10 @@ export default function SettingsPage() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function GeneralSection() { function GeneralSection() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { locale, setLocale } = useLocaleStore();
const { data, isLoading } = useQuery<PlatformSettings>({ const { data, isLoading } = useQuery<PlatformSettings>({
queryKey: queryKeys.settings.general(), queryKey: queryKeys.settings.general(),
@ -160,24 +158,41 @@ function GeneralSection() {
return ( return (
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">General Settings</h2> <h2 className="text-lg font-semibold mb-4">{t('general.title')}</h2>
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">{tc('loading')}</p>
) : ( ) : (
<div className="space-y-4 max-w-lg"> <div className="space-y-4 max-w-lg">
{/* UI Language selector */}
<div> <div>
<label className="block text-sm font-medium mb-1">Platform Name</label> <label className="block text-sm font-medium mb-1">{t('general.uiLanguage')}</label>
<select
className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={locale}
onChange={(e) => setLocale(e.target.value as SupportedLocale)}
>
{UI_LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>{t(l.labelKey)}</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{t('general.uiLanguageHint')}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('general.platformName')}</label>
<input <input
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={form.platformName} value={form.platformName}
onChange={(e) => setForm({ ...form, platformName: e.target.value })} onChange={(e) => setForm({ ...form, platformName: e.target.value })}
placeholder="IT0 Platform" placeholder={t('general.platformNamePlaceholder')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Default Timezone</label> <label className="block text-sm font-medium mb-1">{t('general.defaultTimezone')}</label>
<select <select
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={form.defaultTimezone} value={form.defaultTimezone}
@ -190,21 +205,21 @@ function GeneralSection() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Default Language</label> <label className="block text-sm font-medium mb-1">{t('general.defaultLanguage')}</label>
<select <select
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={form.defaultLanguage} value={form.defaultLanguage}
onChange={(e) => setForm({ ...form, defaultLanguage: e.target.value })} onChange={(e) => setForm({ ...form, defaultLanguage: e.target.value })}
> >
{LANGUAGES.map((l) => ( {PLATFORM_LANGUAGES.map((code) => (
<option key={l.value} value={l.value}>{l.label}</option> <option key={code} value={code}>{t(`languages.${code}`)}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Auto-Approve Threshold (Risk Level 0-3) {t('general.autoApproveThreshold')}
</label> </label>
<input <input
type="number" type="number"
@ -217,7 +232,7 @@ function GeneralSection() {
} }
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Operations at or below this risk level will be auto-approved. {t('general.autoApproveHint')}
</p> </p>
</div> </div>
@ -226,14 +241,14 @@ function GeneralSection() {
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{mutation.isPending ? 'Saving...' : 'Save Changes'} {mutation.isPending ? tc('saving') : tc('save')}
</button> </button>
{mutation.isError && ( {mutation.isError && (
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p> <p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
)} )}
{mutation.isSuccess && ( {mutation.isSuccess && (
<p className="text-sm text-green-600">Settings saved successfully.</p> <p className="text-sm text-green-600">{t('general.saved')}</p>
)} )}
</div> </div>
)} )}
@ -246,6 +261,8 @@ function GeneralSection() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function NotificationsSection() { function NotificationsSection() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery<NotificationSettings>({ const { data, isLoading } = useQuery<NotificationSettings>({
@ -297,30 +314,30 @@ function NotificationsSection() {
return ( return (
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Notification Settings</h2> <h2 className="text-lg font-semibold mb-4">{t('notifications.title')}</h2>
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">{tc('loading')}</p>
) : ( ) : (
<div className="space-y-2 max-w-lg"> <div className="space-y-2 max-w-lg">
<Toggle <Toggle
label="Email Notifications" label={t('notifications.email')}
checked={form.emailEnabled} checked={form.emailEnabled}
onChange={(v) => setForm({ ...form, emailEnabled: v })} onChange={(v) => setForm({ ...form, emailEnabled: v })}
/> />
<Toggle <Toggle
label="SMS Notifications" label={t('notifications.sms')}
checked={form.smsEnabled} checked={form.smsEnabled}
onChange={(v) => setForm({ ...form, smsEnabled: v })} onChange={(v) => setForm({ ...form, smsEnabled: v })}
/> />
<Toggle <Toggle
label="Push Notifications" label={t('notifications.push')}
checked={form.pushEnabled} checked={form.pushEnabled}
onChange={(v) => setForm({ ...form, pushEnabled: v })} onChange={(v) => setForm({ ...form, pushEnabled: v })}
/> />
<div className="pt-4"> <div className="pt-4">
<label className="block text-sm font-medium mb-1">Default Escalation Policy</label> <label className="block text-sm font-medium mb-1">{t('notifications.escalationPolicy')}</label>
<select <select
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={form.defaultEscalationPolicy} value={form.defaultEscalationPolicy}
@ -340,7 +357,7 @@ function NotificationsSection() {
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{mutation.isPending ? 'Saving...' : 'Save Changes'} {mutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
@ -348,7 +365,7 @@ function NotificationsSection() {
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p> <p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
)} )}
{mutation.isSuccess && ( {mutation.isSuccess && (
<p className="text-sm text-green-600">Notification settings saved.</p> <p className="text-sm text-green-600">{t('notifications.saved')}</p>
)} )}
</div> </div>
)} )}
@ -361,6 +378,8 @@ function NotificationsSection() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function ApiKeysSection() { function ApiKeysSection() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState(''); const [newKeyName, setNewKeyName] = useState('');
const [generatedKey, setGeneratedKey] = useState<string | null>(null); const [generatedKey, setGeneratedKey] = useState<string | null>(null);
@ -399,7 +418,7 @@ function ApiKeysSection() {
return ( return (
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">API Keys</h2> <h2 className="text-lg font-semibold mb-4">{t('apikeys.title')}</h2>
{/* Generate new key */} {/* Generate new key */}
<div className="flex gap-2 mb-4 max-w-lg"> <div className="flex gap-2 mb-4 max-w-lg">
@ -407,14 +426,14 @@ function ApiKeysSection() {
className="flex-1 border rounded-md px-3 py-2 bg-background text-sm" className="flex-1 border rounded-md px-3 py-2 bg-background text-sm"
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Key name (e.g. CI/CD Pipeline)" placeholder={t('apikeys.namePlaceholder')}
/> />
<button <button
disabled={!newKeyName.trim() || createMutation.isPending} disabled={!newKeyName.trim() || createMutation.isPending}
onClick={() => createMutation.mutate(newKeyName.trim())} onClick={() => createMutation.mutate(newKeyName.trim())}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
> >
Generate New Key {t('apikeys.generate')}
</button> </button>
</div> </div>
@ -422,7 +441,7 @@ function ApiKeysSection() {
{generatedKey && ( {generatedKey && (
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-md"> <div className="mb-4 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1"> <p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1">
New API key generated. Copy it now -- it will not be shown again. {t('apikeys.generatedWarning')}
</p> </p>
<code className="block text-sm bg-white dark:bg-gray-900 p-2 rounded border font-mono break-all"> <code className="block text-sm bg-white dark:bg-gray-900 p-2 rounded border font-mono break-all">
{generatedKey} {generatedKey}
@ -433,30 +452,30 @@ function ApiKeysSection() {
}} }}
className="mt-2 px-3 py-1 text-xs border rounded hover:bg-muted" className="mt-2 px-3 py-1 text-xs border rounded hover:bg-muted"
> >
Copy to Clipboard {t('apikeys.copyToClipboard')}
</button> </button>
<button <button
onClick={() => setGeneratedKey(null)} onClick={() => setGeneratedKey(null)}
className="mt-2 ml-2 px-3 py-1 text-xs border rounded hover:bg-muted" className="mt-2 ml-2 px-3 py-1 text-xs border rounded hover:bg-muted"
> >
Dismiss {t('apikeys.dismiss')}
</button> </button>
</div> </div>
)} )}
{/* Keys table */} {/* Keys table */}
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">{tc('loading')}</p>
) : ( ) : (
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr className="text-left"> <tr className="text-left">
<th className="px-4 py-3 font-medium">Name</th> <th className="px-4 py-3 font-medium">{t('apikeys.headerName')}</th>
<th className="px-4 py-3 font-medium">Key</th> <th className="px-4 py-3 font-medium">{t('apikeys.headerKey')}</th>
<th className="px-4 py-3 font-medium">Created</th> <th className="px-4 py-3 font-medium">{t('apikeys.headerCreated')}</th>
<th className="px-4 py-3 font-medium">Last Used</th> <th className="px-4 py-3 font-medium">{t('apikeys.headerLastUsed')}</th>
<th className="px-4 py-3 font-medium text-right">Actions</th> <th className="px-4 py-3 font-medium text-right">{t('apikeys.headerActions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
@ -468,7 +487,7 @@ function ApiKeysSection() {
{format(new Date(k.createdAt), 'MMM d, yyyy')} {format(new Date(k.createdAt), 'MMM d, yyyy')}
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 text-muted-foreground">
{k.lastUsedAt ? format(new Date(k.lastUsedAt), 'MMM d, yyyy') : 'Never'} {k.lastUsedAt ? format(new Date(k.lastUsedAt), 'MMM d, yyyy') : tc('never')}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <button
@ -476,7 +495,7 @@ function ApiKeysSection() {
disabled={revokeMutation.isPending} disabled={revokeMutation.isPending}
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900 dark:text-red-300 disabled:opacity-50" className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900 dark:text-red-300 disabled:opacity-50"
> >
Revoke {t('apikeys.revoke')}
</button> </button>
</td> </td>
</tr> </tr>
@ -484,7 +503,7 @@ function ApiKeysSection() {
{apiKeys.length === 0 && ( {apiKeys.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No API keys. Generate one above. {t('apikeys.noKeys')}
</td> </td>
</tr> </tr>
)} )}
@ -505,6 +524,8 @@ function ApiKeysSection() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function ThemeSection() { function ThemeSection() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery<ThemeSettings>({ const { data, isLoading } = useQuery<ThemeSettings>({
@ -530,15 +551,15 @@ function ThemeSection() {
return ( return (
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Theme</h2> <h2 className="text-lg font-semibold mb-4">{t('theme.title')}</h2>
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">{tc('loading')}</p>
) : ( ) : (
<div className="space-y-6 max-w-lg"> <div className="space-y-6 max-w-lg">
{/* Mode toggle */} {/* Mode toggle */}
<div> <div>
<label className="block text-sm font-medium mb-2">Appearance</label> <label className="block text-sm font-medium mb-2">{t('theme.appearance')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['light', 'dark'] as const).map((m) => ( {(['light', 'dark'] as const).map((m) => (
<button <button
@ -550,7 +571,7 @@ function ThemeSection() {
: 'hover:bg-muted' : 'hover:bg-muted'
}`} }`}
> >
{m === 'light' ? 'Light' : 'Dark'} {t(`theme.${m}`)}
</button> </button>
))} ))}
</div> </div>
@ -558,7 +579,7 @@ function ThemeSection() {
{/* Color presets */} {/* Color presets */}
<div> <div>
<label className="block text-sm font-medium mb-2">Primary Color</label> <label className="block text-sm font-medium mb-2">{t('theme.primaryColor')}</label>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{COLOR_PRESETS.map((c) => ( {COLOR_PRESETS.map((c) => (
<button <button
@ -576,11 +597,11 @@ function ThemeSection() {
value={primaryColor} value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)} onChange={(e) => setPrimaryColor(e.target.value)}
className="w-8 h-8 rounded-full cursor-pointer border-0 p-0" className="w-8 h-8 rounded-full cursor-pointer border-0 p-0"
title="Custom color" title={t('theme.customColor')}
/> />
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Selected: {primaryColor} {t('theme.selected', { color: primaryColor })}
</p> </p>
</div> </div>
@ -589,14 +610,14 @@ function ThemeSection() {
disabled={mutation.isPending} disabled={mutation.isPending}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{mutation.isPending ? 'Saving...' : 'Save Theme'} {mutation.isPending ? tc('saving') : t('theme.saveTheme')}
</button> </button>
{mutation.isError && ( {mutation.isError && (
<p className="text-sm text-red-500">{(mutation.error as Error).message}</p> <p className="text-sm text-red-500">{(mutation.error as Error).message}</p>
)} )}
{mutation.isSuccess && ( {mutation.isSuccess && (
<p className="text-sm text-green-600">Theme settings saved.</p> <p className="text-sm text-green-600">{t('theme.saved')}</p>
)} )}
</div> </div>
)} )}
@ -609,6 +630,8 @@ function ThemeSection() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function AccountSection() { function AccountSection() {
const { t } = useTranslation('settings');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery<AccountInfo>({ const { data, isLoading } = useQuery<AccountInfo>({
@ -651,14 +674,14 @@ function AccountSection() {
<div className="space-y-6"> <div className="space-y-6">
{/* Profile card */} {/* Profile card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Account Profile</h2> <h2 className="text-lg font-semibold mb-4">{t('account.profileTitle')}</h2>
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p> <p className="text-muted-foreground text-sm">{tc('loading')}</p>
) : ( ) : (
<div className="space-y-4 max-w-lg"> <div className="space-y-4 max-w-lg">
<div> <div>
<label className="block text-sm font-medium mb-1">Display Name</label> <label className="block text-sm font-medium mb-1">{t('account.displayName')}</label>
<input <input
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
value={displayName} value={displayName}
@ -667,14 +690,14 @@ function AccountSection() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <label className="block text-sm font-medium mb-1">{t('account.email')}</label>
<input <input
className="w-full border rounded-md px-3 py-2 bg-muted text-sm cursor-not-allowed" className="w-full border rounded-md px-3 py-2 bg-muted text-sm cursor-not-allowed"
value={email} value={email}
readOnly readOnly
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Email cannot be changed here. {t('account.emailHint')}
</p> </p>
</div> </div>
@ -683,14 +706,14 @@ function AccountSection() {
disabled={profileMutation.isPending || !displayName.trim()} disabled={profileMutation.isPending || !displayName.trim()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{profileMutation.isPending ? 'Saving...' : 'Save Profile'} {profileMutation.isPending ? tc('saving') : t('account.saveProfile')}
</button> </button>
{profileMutation.isError && ( {profileMutation.isError && (
<p className="text-sm text-red-500">{(profileMutation.error as Error).message}</p> <p className="text-sm text-red-500">{(profileMutation.error as Error).message}</p>
)} )}
{profileMutation.isSuccess && ( {profileMutation.isSuccess && (
<p className="text-sm text-green-600">Profile updated.</p> <p className="text-sm text-green-600">{t('account.profileSaved')}</p>
)} )}
</div> </div>
)} )}
@ -698,11 +721,11 @@ function AccountSection() {
{/* Password card */} {/* Password card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Change Password</h2> <h2 className="text-lg font-semibold mb-4">{t('account.changePassword')}</h2>
<div className="space-y-4 max-w-lg"> <div className="space-y-4 max-w-lg">
<div> <div>
<label className="block text-sm font-medium mb-1">Current Password</label> <label className="block text-sm font-medium mb-1">{t('account.currentPassword')}</label>
<input <input
type="password" type="password"
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
@ -712,7 +735,7 @@ function AccountSection() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">New Password</label> <label className="block text-sm font-medium mb-1">{t('account.newPassword')}</label>
<input <input
type="password" type="password"
className="w-full border rounded-md px-3 py-2 bg-background text-sm" className="w-full border rounded-md px-3 py-2 bg-background text-sm"
@ -722,7 +745,7 @@ function AccountSection() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Confirm New Password</label> <label className="block text-sm font-medium mb-1">{t('account.confirmPassword')}</label>
<input <input
type="password" type="password"
className={`w-full border rounded-md px-3 py-2 bg-background text-sm ${ className={`w-full border rounded-md px-3 py-2 bg-background text-sm ${
@ -732,7 +755,7 @@ function AccountSection() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
/> />
{passwordMismatch && ( {passwordMismatch && (
<p className="text-xs text-red-500 mt-1">Passwords do not match.</p> <p className="text-xs text-red-500 mt-1">{tc('passwordsNoMatch')}</p>
)} )}
</div> </div>
@ -748,14 +771,14 @@ function AccountSection() {
} }
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
> >
{passwordMutation.isPending ? 'Changing...' : 'Change Password'} {passwordMutation.isPending ? t('account.changing') : t('account.changePassword')}
</button> </button>
{passwordMutation.isError && ( {passwordMutation.isError && (
<p className="text-sm text-red-500">{(passwordMutation.error as Error).message}</p> <p className="text-sm text-red-500">{(passwordMutation.error as Error).message}</p>
)} )}
{passwordMutation.isSuccess && ( {passwordMutation.isSuccess && (
<p className="text-sm text-green-600">Password changed successfully.</p> <p className="text-sm text-green-600">{t('account.passwordChanged')}</p>
)} )}
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -233,16 +234,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('standing-orders');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Standing Order</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{name}</strong>? This will stop all future {t('deleteDialog.message')}
executions and cannot be undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -250,14 +253,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -291,6 +294,9 @@ function EditDialog({
onClose: () => void; onClose: () => void;
onSubmit: (data: EditFormData) => void; onSubmit: (data: EditFormData) => void;
}) { }) {
const { t } = useTranslation('standing-orders');
const { t: tc } = useTranslation('common');
const [form, setForm] = useState<EditFormData>({ const [form, setForm] = useState<EditFormData>({
name: '', name: '',
description: '', description: '',
@ -325,12 +331,12 @@ function EditDialog({
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">Edit Standing Order</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.editStandingOrder')}</h2>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1"> <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">{t('form.name')}</label>
<input <input
type="text" type="text"
value={form.name} value={form.name}
@ -341,7 +347,7 @@ function EditDialog({
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-medium mb-1">Description</label> <label className="block text-sm font-medium mb-1">{tc('description')}</label>
<textarea <textarea
value={form.description} value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
@ -352,7 +358,7 @@ function EditDialog({
{/* Agent Instruction */} {/* Agent Instruction */}
<div> <div>
<label className="block text-sm font-medium mb-1">Agent Instruction</label> <label className="block text-sm font-medium mb-1">{t('form.agentInstruction')}</label>
<textarea <textarea
value={form.agentInstruction} value={form.agentInstruction}
onChange={(e) => setForm((prev) => ({ ...prev, agentInstruction: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, agentInstruction: e.target.value }))}
@ -363,19 +369,19 @@ function EditDialog({
{/* Target Servers */} {/* Target Servers */}
<div> <div>
<label className="block text-sm font-medium mb-1">Target Servers</label> <label className="block text-sm font-medium mb-1">{t('form.targetServers')}</label>
<input <input
type="text" type="text"
value={form.targetServers} value={form.targetServers}
onChange={(e) => setForm((prev) => ({ ...prev, targetServers: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, targetServers: e.target.value }))}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="server-1, server-2 (comma-separated)" placeholder={t('form.targetServersPlaceholder')}
/> />
</div> </div>
{/* Max Budget */} {/* Max Budget */}
<div> <div>
<label className="block text-sm font-medium mb-1">Max Budget (USD)</label> <label className="block text-sm font-medium mb-1">{t('form.maxBudget')}</label>
<input <input
type="number" type="number"
min="0" min="0"
@ -397,7 +403,7 @@ function EditDialog({
} }
className="accent-primary h-4 w-4" className="accent-primary h-4 w-4"
/> />
Escalate on failure {t('form.escalateOnFailure')}
</label> </label>
</div> </div>
</div> </div>
@ -409,7 +415,7 @@ function EditDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -417,7 +423,7 @@ function EditDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
@ -430,6 +436,8 @@ function EditDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function StandingOrderDetailPage() { export default function StandingOrderDetailPage() {
const { t } = useTranslation('standing-orders');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
@ -589,7 +597,7 @@ export default function StandingOrderDetailPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading standing order... {t('detail.loading')}
</div> </div>
); );
} }
@ -602,10 +610,10 @@ export default function StandingOrderDetailPage() {
onClick={() => router.push('/standing-orders')} onClick={() => router.push('/standing-orders')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Standing Orders &larr; {t('detail.backToStandingOrders')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load standing order: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -618,10 +626,10 @@ export default function StandingOrderDetailPage() {
onClick={() => router.push('/standing-orders')} onClick={() => router.push('/standing-orders')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1"
> >
&larr; Back to Standing Orders &larr; {t('detail.backToStandingOrders')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Standing order not found. {t('detail.notFound')}
</div> </div>
</div> </div>
); );
@ -636,7 +644,7 @@ export default function StandingOrderDetailPage() {
onClick={() => router.push('/standing-orders')} onClick={() => router.push('/standing-orders')}
className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1 transition-colors" className="text-sm text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1 transition-colors"
> >
&larr; Back to Standing Orders &larr; {t('detail.backToStandingOrders')}
</button> </button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
@ -657,10 +665,10 @@ export default function StandingOrderDetailPage() {
)} )}
> >
{toggleStatusMutation.isPending {toggleStatusMutation.isPending
? 'Updating...' ? tc('loading')
: order.status === 'active' : order.status === 'active'
? 'Pause Order' ? t('detail.pauseOrder')
: 'Activate Order'} : t('detail.activateOrder')}
</button> </button>
</div> </div>
@ -676,19 +684,19 @@ export default function StandingOrderDetailPage() {
{/* Overview card */} {/* Overview card */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Overview</h2> <h2 className="text-base font-semibold">{t('detail.overview')}</h2>
</div> </div>
<div className="px-6 py-4 space-y-5"> <div className="px-6 py-4 space-y-5">
{/* Trigger configuration */} {/* Trigger configuration */}
<div> <div>
<h3 className="text-sm font-medium text-muted-foreground mb-2"> <h3 className="text-sm font-medium text-muted-foreground mb-2">
Trigger Configuration {t('detail.triggerConfiguration')}
</h3> </h3>
<div className="bg-muted/50 rounded-md p-3 space-y-1"> <div className="bg-muted/50 rounded-md p-3 space-y-1">
{order.triggerType === 'cron' && ( {order.triggerType === 'cron' && (
<> <>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Expression:</span> <span className="text-muted-foreground">{t('detail.expression')}</span>
<code className="font-mono text-xs bg-background px-2 py-0.5 rounded border"> <code className="font-mono text-xs bg-background px-2 py-0.5 rounded border">
{order.triggerConfig.cronExpression ?? '--'} {order.triggerConfig.cronExpression ?? '--'}
</code> </code>
@ -702,7 +710,7 @@ export default function StandingOrderDetailPage() {
)} )}
{order.triggerType === 'event' && ( {order.triggerType === 'event' && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Event Type:</span> <span className="text-muted-foreground">{t('detail.eventType')}</span>
<code className="font-mono text-xs bg-background px-2 py-0.5 rounded border"> <code className="font-mono text-xs bg-background px-2 py-0.5 rounded border">
{order.triggerConfig.eventType ?? '--'} {order.triggerConfig.eventType ?? '--'}
</code> </code>
@ -711,11 +719,11 @@ export default function StandingOrderDetailPage() {
{order.triggerType === 'threshold' && ( {order.triggerType === 'threshold' && (
<> <>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Metric:</span> <span className="text-muted-foreground">{t('form.metric')}:</span>
<span className="font-medium">{order.triggerConfig.metric ?? '--'}</span> <span className="font-medium">{order.triggerConfig.metric ?? '--'}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Condition:</span> <span className="text-muted-foreground">{t('form.condition')}:</span>
<span className="font-medium"> <span className="font-medium">
{order.triggerConfig.condition ?? '>='}{' '} {order.triggerConfig.condition ?? '>='}{' '}
{order.triggerConfig.thresholdValue ?? '--'} {order.triggerConfig.thresholdValue ?? '--'}
@ -729,7 +737,7 @@ export default function StandingOrderDetailPage() {
{/* Agent instruction */} {/* Agent instruction */}
<div> <div>
<h3 className="text-sm font-medium text-muted-foreground mb-2"> <h3 className="text-sm font-medium text-muted-foreground mb-2">
Agent Instruction {t('detail.agentInstruction')}
</h3> </h3>
<div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap"> <div className="bg-gray-950 text-gray-200 font-mono text-sm p-4 rounded-md whitespace-pre-wrap">
{order.agentInstruction} {order.agentInstruction}
@ -738,9 +746,9 @@ export default function StandingOrderDetailPage() {
{/* Target servers */} {/* Target servers */}
<div> <div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Target Servers</h3> <h3 className="text-sm font-medium text-muted-foreground mb-2">{t('detail.targetServers')}</h3>
{order.targetServers.length === 0 ? ( {order.targetServers.length === 0 ? (
<p className="text-sm text-muted-foreground">No target servers configured.</p> <p className="text-sm text-muted-foreground">{t('detail.noTargetServers')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{order.targetServers.map((server) => ( {order.targetServers.map((server) => (
@ -757,7 +765,7 @@ export default function StandingOrderDetailPage() {
{/* Max budget */} {/* Max budget */}
<div> <div>
<h3 className="text-sm font-medium text-muted-foreground mb-1">Max Budget</h3> <h3 className="text-sm font-medium text-muted-foreground mb-1">{t('detail.maxBudget')}</h3>
<p className="text-sm font-medium">${order.maxBudgetUsd.toFixed(2)} USD</p> <p className="text-sm font-medium">${order.maxBudgetUsd.toFixed(2)} USD</p>
</div> </div>
</div> </div>
@ -766,18 +774,18 @@ export default function StandingOrderDetailPage() {
{/* Execution History */} {/* Execution History */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Execution History</h2> <h2 className="text-base font-semibold">{t('executionHistory.title')}</h2>
</div> </div>
{executionsLoading && ( {executionsLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading executions... {t('executionHistory.loading')}
</div> </div>
)} )}
{executionsError && ( {executionsError && (
<div className="p-4 mx-6 my-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mx-6 my-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load executions: {(executionsError as Error).message} {t('executionHistory.loadError')} {(executionsError as Error).message}
</div> </div>
)} )}
@ -788,17 +796,17 @@ export default function StandingOrderDetailPage() {
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="w-8 px-4 py-3" /> <th className="w-8 px-4 py-3" />
<th className="text-left px-4 py-3 font-medium">Date</th> <th className="text-left px-4 py-3 font-medium">{t('executionHistory.table.date')}</th>
<th className="text-left px-4 py-3 font-medium">Status</th> <th className="text-left px-4 py-3 font-medium">{t('executionHistory.table.status')}</th>
<th className="text-left px-4 py-3 font-medium">Duration</th> <th className="text-left px-4 py-3 font-medium">{t('executionHistory.table.duration')}</th>
<th className="text-right px-4 py-3 font-medium">Commands</th> <th className="text-right px-4 py-3 font-medium">{t('executionHistory.table.commands')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{executions.length === 0 ? ( {executions.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="text-center text-muted-foreground py-12"> <td colSpan={5} className="text-center text-muted-foreground py-12">
No executions recorded yet. {t('executionHistory.empty')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -839,7 +847,7 @@ export default function StandingOrderDetailPage() {
{exec.durationMs != null {exec.durationMs != null
? formatDuration(exec.durationMs) ? formatDuration(exec.durationMs)
: exec.status === 'running' : exec.status === 'running'
? 'In progress...' ? t('executionHistory.inProgress')
: '--'} : '--'}
</td> </td>
<td className="px-4 py-3 text-right tabular-nums"> <td className="px-4 py-3 text-right tabular-nums">
@ -853,7 +861,7 @@ export default function StandingOrderDetailPage() {
<td colSpan={5} className="bg-muted/10 border-b px-6 py-4"> <td colSpan={5} className="bg-muted/10 border-b px-6 py-4">
<div> <div>
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2"> <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">
Execution Summary {t('executionHistory.executionSummary')}
</h4> </h4>
{exec.summary ? ( {exec.summary ? (
<p className="text-sm text-foreground whitespace-pre-wrap"> <p className="text-sm text-foreground whitespace-pre-wrap">
@ -861,13 +869,13 @@ export default function StandingOrderDetailPage() {
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
No summary available. {t('executionHistory.noSummary')}
</p> </p>
)} )}
<div className="mt-3 flex gap-4 text-xs text-muted-foreground"> <div className="mt-3 flex gap-4 text-xs text-muted-foreground">
<span>Started: {formatDate(exec.startedAt)}</span> <span>{t('executionHistory.started')} {formatDate(exec.startedAt)}</span>
{exec.endedAt && ( {exec.endedAt && (
<span>Ended: {formatDate(exec.endedAt)}</span> <span>{t('executionHistory.ended')} {formatDate(exec.endedAt)}</span>
)} )}
</div> </div>
</div> </div>
@ -885,10 +893,7 @@ export default function StandingOrderDetailPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t"> <div className="flex items-center justify-between px-6 py-3 border-t">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Showing {executionPage * PAGE_SIZE + 1} {t('detail.pagination.showing', { from: executionPage * PAGE_SIZE + 1, to: Math.min((executionPage + 1) * PAGE_SIZE, totalExecutions), total: totalExecutions })}
{' - '}
{Math.min((executionPage + 1) * PAGE_SIZE, totalExecutions)} of{' '}
{totalExecutions}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@ -896,17 +901,17 @@ export default function StandingOrderDetailPage() {
disabled={executionPage === 0} disabled={executionPage === 0}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Previous {t('detail.pagination.previous')}
</button> </button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Page {executionPage + 1} of {totalPages} {t('detail.pagination.pageOf', { current: executionPage + 1, total: totalPages })}
</span> </span>
<button <button
onClick={() => setExecutionPage((p) => Math.min(totalPages - 1, p + 1))} onClick={() => setExecutionPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={executionPage >= totalPages - 1} disabled={executionPage >= totalPages - 1}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Next {t('detail.pagination.next')}
</button> </button>
</div> </div>
</div> </div>
@ -921,12 +926,12 @@ export default function StandingOrderDetailPage() {
{/* Status & Controls card */} {/* Status & Controls card */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Status & Controls</h2> <h2 className="text-base font-semibold">{t('detail.statusAndControls')}</h2>
</div> </div>
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
{/* Status toggle */} {/* Status toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status</span> <span className="text-sm text-muted-foreground">{tc('status')}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={order.status} /> <StatusBadge status={order.status} />
<button <button
@ -956,7 +961,7 @@ export default function StandingOrderDetailPage() {
{/* Escalate on Failure toggle */} {/* Escalate on Failure toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Escalate on Failure</span> <span className="text-sm text-muted-foreground">{t('detail.escalateOnFailure')}</span>
<button <button
onClick={handleToggleEscalate} onClick={handleToggleEscalate}
disabled={toggleEscalateMutation.isPending} disabled={toggleEscalateMutation.isPending}
@ -983,16 +988,16 @@ export default function StandingOrderDetailPage() {
{/* Last execution */} {/* Last execution */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last Execution</span> <span className="text-sm text-muted-foreground">{t('detail.lastExecution')}</span>
<span className="text-sm"> <span className="text-sm">
{order.lastExecutionAt ? formatRelativeTime(order.lastExecutionAt) : 'Never'} {order.lastExecutionAt ? formatRelativeTime(order.lastExecutionAt) : tc('never')}
</span> </span>
</div> </div>
{/* Next scheduled run */} {/* Next scheduled run */}
{order.triggerType === 'cron' && ( {order.triggerType === 'cron' && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Next Run</span> <span className="text-sm text-muted-foreground">{t('detail.nextRun')}</span>
<span className="text-sm"> <span className="text-sm">
{order.nextRunAt ? formatRelativeTime(order.nextRunAt) : '--'} {order.nextRunAt ? formatRelativeTime(order.nextRunAt) : '--'}
</span> </span>
@ -1004,7 +1009,7 @@ export default function StandingOrderDetailPage() {
{/* Quick Actions card */} {/* Quick Actions card */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Quick Actions</h2> <h2 className="text-base font-semibold">{t('detail.quickActions')}</h2>
</div> </div>
<div className="px-6 py-4 space-y-2"> <div className="px-6 py-4 space-y-2">
<button <button
@ -1012,12 +1017,12 @@ export default function StandingOrderDetailPage() {
disabled={executeNowMutation.isPending} disabled={executeNowMutation.isPending}
className="w-full px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="w-full px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
{executeNowMutation.isPending ? 'Triggering...' : 'Execute Now'} {executeNowMutation.isPending ? t('detail.triggering') : t('detail.executeNow')}
</button> </button>
{executeNowMutation.isSuccess && ( {executeNowMutation.isSuccess && (
<p className="text-xs text-green-600 dark:text-green-400"> <p className="text-xs text-green-600 dark:text-green-400">
Execution triggered successfully. {t('detail.executionTriggered')}
</p> </p>
)} )}
@ -1031,21 +1036,21 @@ export default function StandingOrderDetailPage() {
onClick={() => setEditOpen(true)} onClick={() => setEditOpen(true)}
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors" className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {t('detail.edit')}
</button> </button>
<button <button
onClick={() => router.push(`/audit/logs?standingOrderId=${id}`)} onClick={() => router.push(`/audit/logs?standingOrderId=${id}`)}
className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors" className="w-full px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors"
> >
View Logs {t('detail.viewLogs')}
</button> </button>
<button <button
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {t('detail.delete')}
</button> </button>
</div> </div>
</div> </div>
@ -1053,16 +1058,16 @@ export default function StandingOrderDetailPage() {
{/* Statistics card */} {/* Statistics card */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Statistics</h2> <h2 className="text-base font-semibold">{t('detail.statistics')}</h2>
</div> </div>
<div className="px-6 py-4 space-y-4"> <div className="px-6 py-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total Executions</span> <span className="text-sm text-muted-foreground">{t('detail.stats.totalExecutions')}</span>
<span className="text-sm font-medium tabular-nums">{stats.totalExecutions}</span> <span className="text-sm font-medium tabular-nums">{stats.totalExecutions}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Success Rate</span> <span className="text-sm text-muted-foreground">{t('detail.stats.successRate')}</span>
<span <span
className={cn( className={cn(
'text-sm font-medium tabular-nums', 'text-sm font-medium tabular-nums',
@ -1078,16 +1083,16 @@ export default function StandingOrderDetailPage() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Avg Duration</span> <span className="text-sm text-muted-foreground">{t('detail.stats.avgDuration')}</span>
<span className="text-sm font-medium tabular-nums"> <span className="text-sm font-medium tabular-nums">
{stats.avgDurationMs > 0 ? formatDuration(stats.avgDurationMs) : '--'} {stats.avgDurationMs > 0 ? formatDuration(stats.avgDurationMs) : '--'}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last Failure</span> <span className="text-sm text-muted-foreground">{t('detail.stats.lastFailure')}</span>
<span className="text-sm"> <span className="text-sm">
{stats.lastFailureAt ? formatRelativeTime(stats.lastFailureAt) : 'None'} {stats.lastFailureAt ? formatRelativeTime(stats.lastFailureAt) : tc('none')}
</span> </span>
</div> </div>
</div> </div>
@ -1096,23 +1101,23 @@ export default function StandingOrderDetailPage() {
{/* Metadata */} {/* Metadata */}
<div className="border rounded-lg"> <div className="border rounded-lg">
<div className="px-6 py-4 border-b"> <div className="px-6 py-4 border-b">
<h2 className="text-base font-semibold">Metadata</h2> <h2 className="text-base font-semibold">{t('detail.metadata')}</h2>
</div> </div>
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span> <span className="text-sm text-muted-foreground">{tc('created')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(order.createdAt)} {formatDate(order.createdAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Updated</span> <span className="text-sm text-muted-foreground">{tc('updated')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(order.updatedAt)} {formatDate(order.updatedAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">ID</span> <span className="text-sm text-muted-foreground">{tc('id')}</span>
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{order.id} {order.id}
</code> </code>

View File

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -68,12 +69,6 @@ const emptyForm: StandingOrderFormData = {
escalateOnFailure: false, escalateOnFailure: false,
}; };
const TRIGGER_TYPE_OPTIONS: { value: TriggerType; label: string }[] = [
{ value: 'cron', label: 'Cron Schedule' },
{ value: 'event', label: 'Event' },
{ value: 'threshold', label: 'Threshold' },
];
const EVENT_TYPE_OPTIONS = [ const EVENT_TYPE_OPTIONS = [
'server.cpu_high', 'server.cpu_high',
'server.disk_full', 'server.disk_full',
@ -93,6 +88,8 @@ const STATUS_COLORS: Record<string, string> = {
/* ---------- Execution History Sub-component ---------- */ /* ---------- Execution History Sub-component ---------- */
function ExecutionHistory({ orderId }: { orderId: string }) { function ExecutionHistory({ orderId }: { orderId: string }) {
const { t } = useTranslation('standing-orders');
const { data: executions = [], isLoading, error } = useQuery<Execution[]>({ const { data: executions = [], isLoading, error } = useQuery<Execution[]>({
queryKey: queryKeys.standingOrders.executions(orderId), queryKey: queryKeys.standingOrders.executions(orderId),
queryFn: () => queryFn: () =>
@ -101,35 +98,35 @@ function ExecutionHistory({ orderId }: { orderId: string }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="px-4 py-3 text-sm text-muted-foreground">Loading executions...</div> <div className="px-4 py-3 text-sm text-muted-foreground">{t('executionHistory.loading')}</div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="px-4 py-3 text-sm text-destructive-foreground"> <div className="px-4 py-3 text-sm text-destructive-foreground">
Failed to load executions: {(error as Error).message} {t('executionHistory.loadError')} {(error as Error).message}
</div> </div>
); );
} }
if (executions.length === 0) { if (executions.length === 0) {
return ( return (
<div className="px-4 py-3 text-sm text-muted-foreground">No executions recorded yet.</div> <div className="px-4 py-3 text-sm text-muted-foreground">{t('executionHistory.empty')}</div>
); );
} }
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2"> <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-2">
Execution History {t('executionHistory.title')}
</h4> </h4>
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="border-b"> <tr className="border-b">
<th className="text-left py-1.5 pr-4 font-medium">Status</th> <th className="text-left py-1.5 pr-4 font-medium">{t('executionHistory.table.status')}</th>
<th className="text-left py-1.5 pr-4 font-medium">Started</th> <th className="text-left py-1.5 pr-4 font-medium">{t('executionHistory.started')}</th>
<th className="text-left py-1.5 pr-4 font-medium">Completed</th> <th className="text-left py-1.5 pr-4 font-medium">{t('executionHistory.ended')}</th>
<th className="text-left py-1.5 pr-4 font-medium">Summary</th> <th className="text-left py-1.5 pr-4 font-medium">{t('executionHistory.table.summary')}</th>
<th className="text-right py-1.5 font-medium">Cost</th> <th className="text-right py-1.5 font-medium">{t('executionHistory.table.cost')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -165,8 +162,16 @@ function ExecutionHistory({ orderId }: { orderId: string }) {
/* ---------- Main Component ---------- */ /* ---------- Main Component ---------- */
export default function StandingOrdersPage() { export default function StandingOrdersPage() {
const { t } = useTranslation('standing-orders');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const TRIGGER_TYPE_OPTIONS: { value: TriggerType; label: string }[] = [
{ value: 'cron', label: t('triggerTypes.cron') },
{ value: 'event', label: t('triggerTypes.event') },
{ value: 'threshold', label: t('triggerTypes.threshold') },
];
/* Dialog state */ /* Dialog state */
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@ -311,28 +316,28 @@ export default function StandingOrdersPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Standing Orders</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage autonomous operation tasks and execution schedules. {t('subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openCreate} onClick={openCreate}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
> >
New Standing Order {t('newOrder')}
</button> </button>
</div> </div>
{/* Loading / Error */} {/* Loading / Error */}
{isLoading && ( {isLoading && (
<div className="text-muted-foreground text-sm py-12 text-center"> <div className="text-muted-foreground text-sm py-12 text-center">
Loading standing orders... {t('loading')}
</div> </div>
)} )}
{error && ( {error && (
<div className="text-destructive text-sm py-4"> <div className="text-destructive text-sm py-4">
Failed to load standing orders: {(error as Error).message} {t('loadError')} {(error as Error).message}
</div> </div>
)} )}
@ -343,18 +348,18 @@ export default function StandingOrdersPage() {
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="w-8 px-4 py-3" /> <th className="w-8 px-4 py-3" />
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Trigger</th> <th className="text-left px-4 py-3 font-medium">{t('table.trigger')}</th>
<th className="text-center px-4 py-3 font-medium">Status</th> <th className="text-center px-4 py-3 font-medium">{t('table.status')}</th>
<th className="text-left px-4 py-3 font-medium">Last Execution</th> <th className="text-left px-4 py-3 font-medium">{t('table.lastExecution')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('table.actions')}</th>
</tr> </tr>
</thead> </thead>
{orders.length === 0 ? ( {orders.length === 0 ? (
<tbody> <tbody>
<tr> <tr>
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground"> <td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
No standing orders yet. Click &quot;New Standing Order&quot; to create one. {t('empty')}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -435,7 +440,7 @@ export default function StandingOrdersPage() {
? formatDistanceToNow(new Date(order.lastExecutionAt), { ? formatDistanceToNow(new Date(order.lastExecutionAt), {
addSuffix: true, addSuffix: true,
}) })
: 'Never'} : tc('never')}
</td> </td>
<td className="px-4 py-3 text-right space-x-2"> <td className="px-4 py-3 text-right space-x-2">
@ -446,7 +451,7 @@ export default function StandingOrdersPage() {
}} }}
className="text-xs text-primary hover:underline" className="text-xs text-primary hover:underline"
> >
Edit {tc('edit')}
</button> </button>
{deleteConfirmId === order.id ? ( {deleteConfirmId === order.id ? (
<> <>
@ -458,7 +463,7 @@ export default function StandingOrdersPage() {
className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80" className="text-xs text-destructive-foreground bg-destructive px-2 py-0.5 rounded hover:bg-destructive/80"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
Confirm {tc('confirm')}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@ -467,7 +472,7 @@ export default function StandingOrdersPage() {
}} }}
className="text-xs text-muted-foreground hover:underline" className="text-xs text-muted-foreground hover:underline"
> >
Cancel {tc('cancel')}
</button> </button>
</> </>
) : ( ) : (
@ -478,7 +483,7 @@ export default function StandingOrdersPage() {
}} }}
className="text-xs text-destructive-foreground hover:underline" className="text-xs text-destructive-foreground hover:underline"
> >
Delete {tc('delete')}
</button> </button>
)} )}
</td> </td>
@ -509,7 +514,7 @@ export default function StandingOrdersPage() {
<div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4"> <div className="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-card border rounded-lg shadow-xl mx-4">
<div className="flex items-center justify-between px-6 pt-6 pb-2"> <div className="flex items-center justify-between px-6 pt-6 pb-2">
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{editingId ? 'Edit Standing Order' : 'New Standing Order'} {editingId ? t('detail.editStandingOrder') : t('newOrder')}
</h2> </h2>
<button <button
onClick={closeDialog} onClick={closeDialog}
@ -522,7 +527,7 @@ export default function StandingOrdersPage() {
<form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4"> <form onSubmit={handleSubmit} className="px-6 pb-6 space-y-4">
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium mb-1">Name</label> <label className="block text-sm font-medium mb-1">{t('form.name')}</label>
<input <input
type="text" type="text"
required required
@ -535,12 +540,12 @@ export default function StandingOrdersPage() {
{/* Trigger Config Section */} {/* Trigger Config Section */}
<fieldset className="border rounded-md p-4 space-y-3"> <fieldset className="border rounded-md p-4 space-y-3">
<legend className="text-sm font-medium px-1">Trigger Configuration</legend> <legend className="text-sm font-medium px-1">{t('form.triggerConfiguration')}</legend>
{/* Trigger Type */} {/* Trigger Type */}
<div> <div>
<label className="block text-xs font-medium mb-1 text-muted-foreground"> <label className="block text-xs font-medium mb-1 text-muted-foreground">
Trigger Type {t('form.triggerType')}
</label> </label>
<select <select
value={form.triggerType} value={form.triggerType}
@ -561,7 +566,7 @@ export default function StandingOrdersPage() {
{form.triggerType === 'cron' && ( {form.triggerType === 'cron' && (
<div> <div>
<label className="block text-xs font-medium mb-1 text-muted-foreground"> <label className="block text-xs font-medium mb-1 text-muted-foreground">
Cron Expression {t('form.cronExpression')}
</label> </label>
<input <input
type="text" type="text"
@ -581,7 +586,7 @@ export default function StandingOrdersPage() {
{form.triggerType === 'event' && ( {form.triggerType === 'event' && (
<div> <div>
<label className="block text-xs font-medium mb-1 text-muted-foreground"> <label className="block text-xs font-medium mb-1 text-muted-foreground">
Event Type {t('form.eventType')}
</label> </label>
<select <select
value={form.eventType} value={form.eventType}
@ -603,7 +608,7 @@ export default function StandingOrdersPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium mb-1 text-muted-foreground"> <label className="block text-xs font-medium mb-1 text-muted-foreground">
Metric {t('form.metric')}
</label> </label>
<input <input
type="text" type="text"
@ -616,7 +621,7 @@ export default function StandingOrdersPage() {
</div> </div>
<div> <div>
<label className="block text-xs font-medium mb-1 text-muted-foreground"> <label className="block text-xs font-medium mb-1 text-muted-foreground">
Threshold {t('form.thresholdValue')}
</label> </label>
<input <input
type="number" type="number"
@ -634,13 +639,13 @@ export default function StandingOrdersPage() {
{/* Target Servers */} {/* Target Servers */}
<div> <div>
<label className="block text-sm font-medium mb-1">Target Servers</label> <label className="block text-sm font-medium mb-1">{t('form.targetServers')}</label>
<input <input
type="text" type="text"
value={form.targetServers} value={form.targetServers}
onChange={(e) => setForm({ ...form, targetServers: e.target.value })} onChange={(e) => setForm({ ...form, targetServers: e.target.value })}
className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full px-3 py-2 rounded-md bg-background border text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="server-1, server-2, server-3 (comma-separated IDs)" placeholder={t('form.targetServersPlaceholder')}
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Comma-separated server IDs this order applies to. Comma-separated server IDs this order applies to.
@ -649,7 +654,7 @@ export default function StandingOrdersPage() {
{/* Agent Instruction */} {/* Agent Instruction */}
<div> <div>
<label className="block text-sm font-medium mb-1">Agent Instruction</label> <label className="block text-sm font-medium mb-1">{t('form.agentInstruction')}</label>
<textarea <textarea
value={form.agentInstruction} value={form.agentInstruction}
onChange={(e) => setForm({ ...form, agentInstruction: e.target.value })} onChange={(e) => setForm({ ...form, agentInstruction: e.target.value })}
@ -661,7 +666,7 @@ export default function StandingOrdersPage() {
{/* Max Budget */} {/* Max Budget */}
<div> <div>
<label className="block text-sm font-medium mb-1">Max Budget ($)</label> <label className="block text-sm font-medium mb-1">{t('form.maxBudget')}</label>
<input <input
type="number" type="number"
min="0" min="0"
@ -684,7 +689,7 @@ export default function StandingOrdersPage() {
} }
className="accent-primary h-4 w-4" className="accent-primary h-4 w-4"
/> />
Escalate on failure {t('form.escalateOnFailure')}
</label> </label>
<p className="text-xs text-muted-foreground mt-1 ml-6"> <p className="text-xs text-muted-foreground mt-1 ml-6">
Notify administrators when an execution fails. Notify administrators when an execution fails.
@ -705,7 +710,7 @@ export default function StandingOrdersPage() {
onClick={closeDialog} onClick={closeDialog}
className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors" className="px-4 py-2 rounded-md border text-sm hover:bg-muted transition-colors"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="submit" type="submit"
@ -713,10 +718,10 @@ export default function StandingOrdersPage() {
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{isSaving {isSaving
? 'Saving...' ? tc('saving')
: editingId : editingId
? 'Update Standing Order' ? t('detail.editStandingOrder')
: 'Create Standing Order'} : t('newOrder')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -134,16 +135,18 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('tenants');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete Tenant</h2> <h2 className="text-lg font-semibold mb-2">{t('detail.deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
Are you sure you want to delete <strong>{name}</strong>? This will permanently remove the {t('detail.deleteDialog.message')}
tenant, all its data, and all member associations. This action cannot be undone.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -151,14 +154,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -212,6 +215,8 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function TenantDetailPage() { export default function TenantDetailPage() {
const { t } = useTranslation('tenants');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -343,10 +348,10 @@ export default function TenantDetailPage() {
const validate = useCallback((): boolean => { const validate = useCallback((): boolean => {
const next: Partial<Record<keyof TenantFormData, string>> = {}; const next: Partial<Record<keyof TenantFormData, string>> = {};
if (!form.name.trim()) next.name = 'Name is required'; if (!form.name.trim()) next.name = t('detail.validation.nameRequired');
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form, t]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (!validate()) return; if (!validate()) return;
@ -387,10 +392,10 @@ export default function TenantDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Tenants {t('detail.backToTenants')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading tenant details... {t('detail.loading')}
</div> </div>
</div> </div>
); );
@ -418,10 +423,10 @@ export default function TenantDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Tenants {t('detail.backToTenants')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load tenant: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -450,7 +455,7 @@ export default function TenantDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Tenants {t('detail.backToTenants')}
</button> </button>
{/* Page header */} {/* Page header */}
@ -467,13 +472,13 @@ export default function TenantDetailPage() {
{/* Tenant Information Card */} {/* Tenant Information Card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Tenant Information</h2> <h2 className="text-lg font-semibold">{t('detail.tenantInformation')}</h2>
{!isEditing && ( {!isEditing && (
<button <button
onClick={startEditing} onClick={startEditing}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -484,7 +489,7 @@ export default function TenantDetailPage() {
{/* name */} {/* name */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Name <span className="text-destructive">*</span> {tc('name')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -503,35 +508,35 @@ export default function TenantDetailPage() {
{/* plan */} {/* plan */}
<div> <div>
<label className="block text-sm font-medium mb-1">Plan</label> <label className="block text-sm font-medium mb-1">{t('form.plan')}</label>
<select <select
value={form.plan} value={form.plan}
onChange={(e) => handleChange('plan', e.target.value)} onChange={(e) => handleChange('plan', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
> >
<option value="free">Free</option> <option value="free">{t('plans.free')}</option>
<option value="pro">Pro</option> <option value="pro">{t('plans.pro')}</option>
<option value="enterprise">Enterprise</option> <option value="enterprise">{t('plans.enterprise')}</option>
</select> </select>
</div> </div>
{/* status */} {/* status */}
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">{t('form.status')}</label>
<select <select
value={form.status} value={form.status}
onChange={(e) => handleChange('status', e.target.value)} onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
> >
<option value="active">Active</option> <option value="active">{t('statuses.active')}</option>
<option value="suspended">Suspended</option> <option value="suspended">{t('statuses.suspended')}</option>
</select> </select>
</div> </div>
{/* Update error */} {/* Update error */}
{updateMutation.isError && ( {updateMutation.isError && (
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to update tenant: {(updateMutation.error as Error).message} {t('detail.failedToUpdate')} {(updateMutation.error as Error).message}
</div> </div>
)} )}
@ -543,7 +548,7 @@ export default function TenantDetailPage() {
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -551,22 +556,22 @@ export default function TenantDetailPage() {
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
/* ---------- Read-only info display ---------- */ /* ---------- Read-only info display ---------- */
<dl className="divide-y"> <dl className="divide-y">
<InfoRow label="Name" value={tenant.name} /> <InfoRow label={tc('name')} value={tenant.name} />
<InfoRow <InfoRow
label="Slug" label={t('detail.slug')}
value={ value={
<span className="font-mono text-xs">{tenant.slug}</span> <span className="font-mono text-xs">{tenant.slug}</span>
} }
/> />
<InfoRow <InfoRow
label="Schema Name" label={t('detail.schemaName')}
value={ value={
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded"> <code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{tenant.schemaName} {tenant.schemaName}
@ -574,37 +579,37 @@ export default function TenantDetailPage() {
} }
/> />
<InfoRow <InfoRow
label="Plan" label={t('form.plan')}
value={<PlanBadge plan={tenant.plan} />} value={<PlanBadge plan={tenant.plan} />}
/> />
<InfoRow <InfoRow
label="Status" label={tc('status')}
value={<StatusBadge status={tenant.status} />} value={<StatusBadge status={tenant.status} />}
/> />
<InfoRow label="Members" value={String(tenant.memberCount)} /> <InfoRow label={t('detail.memberCount')} value={String(tenant.memberCount)} />
<InfoRow label="Created" value={formatDate(tenant.createdAt)} /> <InfoRow label={tc('created')} value={formatDate(tenant.createdAt)} />
<InfoRow label="Updated" value={formatDate(tenant.updatedAt)} /> <InfoRow label={tc('updated')} value={formatDate(tenant.updatedAt)} />
</dl> </dl>
)} )}
</div> </div>
{/* Member List */} {/* Member List */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Members</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.members')}</h2>
{members.length === 0 ? ( {members.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No members found. {t('detail.noMembers')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Name</th> <th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.name')}</th>
<th className="text-left px-3 py-2 font-medium">Email</th> <th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.email')}</th>
<th className="text-left px-3 py-2 font-medium">Role</th> <th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.role')}</th>
<th className="text-right px-3 py-2 font-medium">Joined</th> <th className="text-right px-3 py-2 font-medium">{t('detail.membersTable.joined')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -634,37 +639,37 @@ export default function TenantDetailPage() {
{/* Invitations */} {/* Invitations */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Invitations</h2> <h2 className="text-lg font-semibold">{t('detail.invitations')}</h2>
<button <button
onClick={() => setShowInviteForm(!showInviteForm)} onClick={() => setShowInviteForm(!showInviteForm)}
className="px-3 py-1 text-xs rounded-md bg-primary text-primary-foreground hover:opacity-90" className="px-3 py-1 text-xs rounded-md bg-primary text-primary-foreground hover:opacity-90"
> >
+ Invite User {t('detail.inviteUser')}
</button> </button>
</div> </div>
{showInviteForm && ( {showInviteForm && (
<div className="mb-4 p-4 border rounded-md bg-muted/30 space-y-3"> <div className="mb-4 p-4 border rounded-md bg-muted/30 space-y-3">
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <label className="block text-sm font-medium mb-1">{t('detail.inviteForm.email')}</label>
<input <input
type="email" type="email"
value={inviteEmail} value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)} onChange={(e) => setInviteEmail(e.target.value)}
className="w-full px-3 py-2 bg-background border rounded-md text-sm" className="w-full px-3 py-2 bg-background border rounded-md text-sm"
placeholder="user@example.com" placeholder={t('detail.inviteForm.emailPlaceholder')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('detail.inviteForm.role')}</label>
<select <select
value={inviteRole} value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)} onChange={(e) => setInviteRole(e.target.value)}
className="w-full px-3 py-2 bg-background border rounded-md text-sm" className="w-full px-3 py-2 bg-background border rounded-md text-sm"
> >
<option value="viewer">Viewer</option> <option value="viewer">{t('detail.inviteForm.roles.viewer')}</option>
<option value="operator">Operator</option> <option value="operator">{t('detail.inviteForm.roles.operator')}</option>
<option value="admin">Admin</option> <option value="admin">{t('detail.inviteForm.roles.admin')}</option>
</select> </select>
</div> </div>
{sendInviteMutation.isError && ( {sendInviteMutation.isError && (
@ -678,13 +683,13 @@ export default function TenantDetailPage() {
disabled={!inviteEmail || sendInviteMutation.isPending} disabled={!inviteEmail || sendInviteMutation.isPending}
className="px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50" className="px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
> >
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invite'} {sendInviteMutation.isPending ? t('detail.sending') : t('detail.sendInvite')}
</button> </button>
<button <button
onClick={() => { setShowInviteForm(false); setInviteEmail(''); }} onClick={() => { setShowInviteForm(false); setInviteEmail(''); }}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-muted" className="px-3 py-1.5 text-xs border rounded-md hover:bg-muted"
> >
Cancel {tc('cancel')}
</button> </button>
</div> </div>
</div> </div>
@ -692,17 +697,17 @@ export default function TenantDetailPage() {
{invites.length === 0 ? ( {invites.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No invitations sent yet. {t('detail.noInvitations')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Email</th> <th className="text-left px-3 py-2 font-medium">{t('detail.invitesTable.email')}</th>
<th className="text-left px-3 py-2 font-medium">Role</th> <th className="text-left px-3 py-2 font-medium">{t('detail.invitesTable.role')}</th>
<th className="text-left px-3 py-2 font-medium">Status</th> <th className="text-left px-3 py-2 font-medium">{t('detail.invitesTable.status')}</th>
<th className="text-right px-3 py-2 font-medium">Actions</th> <th className="text-right px-3 py-2 font-medium">{t('detail.invitesTable.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -728,7 +733,7 @@ export default function TenantDetailPage() {
disabled={revokeInviteMutation.isPending} disabled={revokeInviteMutation.isPending}
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded" className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
> >
Revoke {t('detail.revoke')}
</button> </button>
)} )}
</td> </td>
@ -745,7 +750,7 @@ export default function TenantDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Quick Actions */} {/* Quick Actions */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.quickActions')}</h2>
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={handleToggleStatus} onClick={handleToggleStatus}
@ -780,8 +785,8 @@ export default function TenantDetailPage() {
{suspendMutation.isPending {suspendMutation.isPending
? 'Updating...' ? 'Updating...'
: tenant.status === 'active' : tenant.status === 'active'
? 'Suspend Tenant' ? t('detail.suspendTenant')
: 'Activate Tenant'} : t('detail.activateTenant')}
</button> </button>
{suspendMutation.isError && ( {suspendMutation.isError && (
@ -811,7 +816,7 @@ export default function TenantDetailPage() {
<line x1="16" x2="8" y1="17" y2="17" /> <line x1="16" x2="8" y1="17" y2="17" />
<polyline points="10 9 9 9 8 9" /> <polyline points="10 9 9 9 8 9" />
</svg> </svg>
View Audit Log {t('detail.viewAuditLog')}
</button> </button>
<button <button
@ -832,7 +837,7 @@ export default function TenantDetailPage() {
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" /> <path d="m15 5 4 4" />
</svg> </svg>
Edit Tenant {t('detail.editTenant')}
</button> </button>
<button <button
@ -856,39 +861,39 @@ export default function TenantDetailPage() {
<line x1="10" x2="10" y1="11" y2="17" /> <line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" /> <line x1="14" x2="14" y1="11" y2="17" />
</svg> </svg>
Delete Tenant {t('detail.deleteTenant')}
</button> </button>
</div> </div>
</div> </div>
{/* Tenant Metadata */} {/* Tenant Metadata */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Metadata</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.metadata')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tenant ID</span> <span className="text-sm text-muted-foreground">{t('detail.tenantId')}</span>
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{tenant.id} {tenant.id}
</code> </code>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Slug</span> <span className="text-sm text-muted-foreground">{t('detail.slug')}</span>
<span className="text-sm font-mono">{tenant.slug}</span> <span className="text-sm font-mono">{tenant.slug}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Schema</span> <span className="text-sm text-muted-foreground">{t('detail.schema')}</span>
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{tenant.schemaName} {tenant.schemaName}
</code> </code>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Created</span> <span className="text-sm text-muted-foreground">{tc('created')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(tenant.createdAt)} {formatDate(tenant.createdAt)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Updated</span> <span className="text-sm text-muted-foreground">{tc('updated')}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(tenant.updatedAt)} {formatDate(tenant.updatedAt)}
</span> </span>

View File

@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -64,6 +65,8 @@ function slugify(value: string) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function TenantsPage() { export default function TenantsPage() {
const { t } = useTranslation('tenants');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
/* ---- local state ---- */ /* ---- local state ---- */
@ -127,10 +130,10 @@ export default function TenantsPage() {
if (!slugTouched) setFormSlug(slugify(value)); if (!slugTouched) setFormSlug(slugify(value));
} }
function startEdit(t: Tenant) { function startEdit(tenant: Tenant) {
setEditingId(t.id); setEditingId(tenant.id);
setEditPlan(t.plan); setEditPlan(tenant.plan);
setEditQuota({ ...t.quota }); setEditQuota({ ...tenant.quota });
} }
function saveEdit(id: string) { function saveEdit(id: string) {
@ -146,16 +149,16 @@ export default function TenantsPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Tenant Management</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage tenants, plans, and resource quotas. {t('subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90" className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90"
> >
+ New Tenant {t('newTenant')}
</button> </button>
</div> </div>
@ -163,9 +166,9 @@ export default function TenantsPage() {
{showCreate && ( {showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6"> <div className="bg-card border rounded-lg shadow-lg w-full max-w-md p-6">
<h2 className="text-lg font-semibold mb-4">Create New Tenant</h2> <h2 className="text-lg font-semibold mb-4">{t('createTenant')}</h2>
<label className="block text-sm font-medium mb-1">Name *</label> <label className="block text-sm font-medium mb-1">{t('form.name')} *</label>
<input <input
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm" className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
value={formName} value={formName}
@ -173,7 +176,7 @@ export default function TenantsPage() {
placeholder="Acme Corp" placeholder="Acme Corp"
/> />
<label className="block text-sm font-medium mb-1">Slug</label> <label className="block text-sm font-medium mb-1">{t('form.slug')}</label>
<input <input
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm" className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
value={formSlug} value={formSlug}
@ -181,18 +184,18 @@ export default function TenantsPage() {
placeholder="acme-corp" placeholder="acme-corp"
/> />
<label className="block text-sm font-medium mb-1">Plan</label> <label className="block text-sm font-medium mb-1">{t('form.plan')}</label>
<select <select
className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm" className="w-full border rounded-md px-3 py-2 mb-3 bg-background text-sm"
value={formPlan} value={formPlan}
onChange={(e) => setFormPlan(e.target.value as Tenant['plan'])} onChange={(e) => setFormPlan(e.target.value as Tenant['plan'])}
> >
{PLANS.map((p) => ( {PLANS.map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option> <option key={p} value={p}>{t(`plans.${p}`)}</option>
))} ))}
</select> </select>
<label className="block text-sm font-medium mb-1">Admin Email *</label> <label className="block text-sm font-medium mb-1">{t('form.adminEmail')} *</label>
<input <input
className="w-full border rounded-md px-3 py-2 mb-4 bg-background text-sm" className="w-full border rounded-md px-3 py-2 mb-4 bg-background text-sm"
type="email" type="email"
@ -206,7 +209,7 @@ export default function TenantsPage() {
onClick={resetCreateForm} onClick={resetCreateForm}
className="px-4 py-2 text-sm border rounded-md hover:bg-muted" className="px-4 py-2 text-sm border rounded-md hover:bg-muted"
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
disabled={!formName || !formEmail || createMutation.isPending} disabled={!formName || !formEmail || createMutation.isPending}
@ -220,7 +223,7 @@ export default function TenantsPage() {
} }
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50" className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
> >
{createMutation.isPending ? 'Creating...' : 'Create Tenant'} {createMutation.isPending ? t('createDialog.creating') : t('createDialog.create')}
</button> </button>
</div> </div>
@ -234,8 +237,8 @@ export default function TenantsPage() {
)} )}
{/* ---- Loading / Error ---- */} {/* ---- Loading / Error ---- */}
{isLoading && <p className="text-muted-foreground">Loading tenants...</p>} {isLoading && <p className="text-muted-foreground">{t('loading')}</p>}
{error && <p className="text-red-500">Failed to load tenants: {(error as Error).message}</p>} {error && <p className="text-red-500">{t('loadError')} {(error as Error).message}</p>}
{/* ---- Tenant Table ---- */} {/* ---- Tenant Table ---- */}
{!isLoading && !error && ( {!isLoading && !error && (
@ -243,94 +246,94 @@ export default function TenantsPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr className="text-left"> <tr className="text-left">
<th className="px-4 py-3 font-medium">Name</th> <th className="px-4 py-3 font-medium">{t('table.name')}</th>
<th className="px-4 py-3 font-medium">Slug</th> <th className="px-4 py-3 font-medium">{t('table.slug')}</th>
<th className="px-4 py-3 font-medium">Plan</th> <th className="px-4 py-3 font-medium">{t('table.plan')}</th>
<th className="px-4 py-3 font-medium">Status</th> <th className="px-4 py-3 font-medium">{t('table.status')}</th>
<th className="px-4 py-3 font-medium text-right">Users</th> <th className="px-4 py-3 font-medium text-right">{t('table.users')}</th>
<th className="px-4 py-3 font-medium">Created</th> <th className="px-4 py-3 font-medium">{t('table.created')}</th>
<th className="px-4 py-3 font-medium text-right">Actions</th> <th className="px-4 py-3 font-medium text-right">{t('table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{tenants.map((t) => ( {tenants.map((tenant) => (
<> <>
{/* Main row */} {/* Main row */}
<tr key={t.id} className="hover:bg-muted/30"> <tr key={tenant.id} className="hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{t.name}</td> <td className="px-4 py-3 font-medium">{tenant.name}</td>
<td className="px-4 py-3 text-muted-foreground">{t.slug}</td> <td className="px-4 py-3 text-muted-foreground">{tenant.slug}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{editingId === t.id ? ( {editingId === tenant.id ? (
<select <select
className="border rounded px-2 py-1 text-xs bg-background" className="border rounded px-2 py-1 text-xs bg-background"
value={editPlan} value={editPlan}
onChange={(e) => setEditPlan(e.target.value as Tenant['plan'])} onChange={(e) => setEditPlan(e.target.value as Tenant['plan'])}
> >
{PLANS.map((p) => ( {PLANS.map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option> <option key={p} value={p}>{t(`plans.${p}`)}</option>
))} ))}
</select> </select>
) : ( ) : (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${planBadge[t.plan]}`}> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${planBadge[tenant.plan]}`}>
{t.plan} {tenant.plan}
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge[t.status]}`}> <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${statusBadge[tenant.status]}`}>
{t.status} {tenant.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right">{t.userCount}</td> <td className="px-4 py-3 text-right">{tenant.userCount}</td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 text-muted-foreground">
{format(new Date(t.createdAt), 'MMM d, yyyy')} {format(new Date(tenant.createdAt), 'MMM d, yyyy')}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
{editingId === t.id ? ( {editingId === tenant.id ? (
<> <>
<button <button
onClick={() => saveEdit(t.id)} onClick={() => saveEdit(tenant.id)}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-2 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 disabled:opacity-50" className="px-2 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 disabled:opacity-50"
> >
Save {tc('save')}
</button> </button>
<button <button
onClick={() => setEditingId(null)} onClick={() => setEditingId(null)}
className="px-2 py-1 text-xs border rounded hover:bg-muted" className="px-2 py-1 text-xs border rounded hover:bg-muted"
> >
Cancel {tc('cancel')}
</button> </button>
</> </>
) : ( ) : (
<> <>
<button <button
onClick={() => startEdit(t)} onClick={() => startEdit(tenant)}
className="px-2 py-1 text-xs border rounded hover:bg-muted" className="px-2 py-1 text-xs border rounded hover:bg-muted"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => onClick={() =>
toggleStatusMutation.mutate({ toggleStatusMutation.mutate({
id: t.id, id: tenant.id,
status: t.status === 'active' ? 'suspended' : 'active', status: tenant.status === 'active' ? 'suspended' : 'active',
}) })
} }
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
t.status === 'active' tenant.status === 'active'
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-300' ? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-300'
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300' : 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300'
}`} }`}
> >
{t.status === 'active' ? 'Suspend' : 'Activate'} {tenant.status === 'active' ? t('actions.suspend') : t('actions.activate')}
</button> </button>
<button <button
onClick={() => setExpandedId(expandedId === t.id ? null : t.id)} onClick={() => setExpandedId(expandedId === tenant.id ? null : tenant.id)}
className="px-2 py-1 text-xs border rounded hover:bg-muted" className="px-2 py-1 text-xs border rounded hover:bg-muted"
> >
{expandedId === t.id ? 'Hide Quotas' : 'Quotas'} {expandedId === tenant.id ? t('actions.hideQuotas') : t('actions.quotas')}
</button> </button>
</> </>
)} )}
@ -339,12 +342,12 @@ export default function TenantsPage() {
</tr> </tr>
{/* Expanded quota row */} {/* Expanded quota row */}
{expandedId === t.id && ( {expandedId === tenant.id && (
<tr key={`${t.id}-quota`} className="bg-muted/20"> <tr key={`${tenant.id}-quota`} className="bg-muted/20">
<td colSpan={7} className="px-4 py-4"> <td colSpan={7} className="px-4 py-4">
<QuotaEditor <QuotaEditor
quota={editingId === t.id ? editQuota : t.quota} quota={editingId === tenant.id ? editQuota : tenant.quota}
editable={editingId === t.id} editable={editingId === tenant.id}
onChange={setEditQuota} onChange={setEditQuota}
/> />
</td> </td>
@ -356,7 +359,7 @@ export default function TenantsPage() {
{tenants.length === 0 && ( {tenants.length === 0 && (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground"> <td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
No tenants found. Create your first tenant to get started. {t('empty')}
</td> </td>
</tr> </tr>
)} )}
@ -381,16 +384,18 @@ function QuotaEditor({
editable: boolean; editable: boolean;
onChange: (q: TenantQuota) => void; onChange: (q: TenantQuota) => void;
}) { }) {
const { t } = useTranslation('tenants');
const fields: { key: keyof TenantQuota; label: string }[] = [ const fields: { key: keyof TenantQuota; label: string }[] = [
{ key: 'maxServers', label: 'Max Servers' }, { key: 'maxServers', label: t('quotas.maxServers') },
{ key: 'maxUsers', label: 'Max Users' }, { key: 'maxUsers', label: t('quotas.maxUsers') },
{ key: 'maxStandingOrders', label: 'Max Standing Orders' }, { key: 'maxStandingOrders', label: t('quotas.maxStandingOrders') },
{ key: 'maxAgentTokensPerMonth', label: 'Max Agent Tokens / Month' }, { key: 'maxAgentTokensPerMonth', label: t('quotas.maxAgentTokens') },
]; ];
return ( return (
<div> <div>
<h3 className="text-sm font-semibold mb-3">Resource Quotas</h3> <h3 className="text-sm font-semibold mb-3">{t('quotas.title')}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{fields.map(({ key, label }) => ( {fields.map(({ key, label }) => (
<div key={key}> <div key={key}>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
@ -69,22 +70,23 @@ function getWsBaseUrl(): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function StatusDot({ status }: { status: ConnectionStatus }) { function StatusDot({ status }: { status: ConnectionStatus }) {
const { t } = useTranslation('terminal');
const colorMap: Record<ConnectionStatus, string> = { const colorMap: Record<ConnectionStatus, string> = {
connected: 'bg-green-500', connected: 'bg-green-500',
connecting: 'bg-yellow-500 animate-pulse', connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-red-500', disconnected: 'bg-red-500',
}; };
const labelMap: Record<ConnectionStatus, string> = { const labelKeyMap: Record<ConnectionStatus, string> = {
connected: 'Connected', connected: 'connection.connected',
connecting: 'Connecting...', connecting: 'connection.connecting',
disconnected: 'Disconnected', disconnected: 'connection.disconnected',
}; };
return ( return (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn('w-2 h-2 rounded-full inline-block', colorMap[status])} /> <span className={cn('w-2 h-2 rounded-full inline-block', colorMap[status])} />
{labelMap[status]} {t(labelKeyMap[status])}
</span> </span>
); );
} }
@ -114,6 +116,7 @@ function TerminalLineRow({ line }: { line: TerminalLine }) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function TerminalPage() { export default function TerminalPage() {
const { t } = useTranslation('terminal');
// ---- State ---- // ---- State ----
const [selectedServerId, setSelectedServerId] = useState<string>(''); const [selectedServerId, setSelectedServerId] = useState<string>('');
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected'); const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected');
@ -164,7 +167,7 @@ export default function TerminalPage() {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem('access_token');
if (!token) { if (!token) {
addLine('error', 'No authentication token found. Please log in first.'); addLine('error', t('messages.noAuthToken'));
return; return;
} }
@ -172,7 +175,7 @@ export default function TerminalPage() {
const hostname = selectedServer?.hostname ?? selectedServerId; const hostname = selectedServer?.hostname ?? selectedServerId;
setConnectionStatus('connecting'); setConnectionStatus('connecting');
addLine('system', `Connecting to ${hostname} (${selectedServer?.host ?? '...'})...`); addLine('system', t('messages.connecting', { hostname, host: selectedServer?.host ?? '...' }));
const wsBase = getWsBaseUrl(); const wsBase = getWsBaseUrl();
const wsUrl = `${wsBase}/ws/terminal?serverId=${encodeURIComponent(selectedServerId)}&token=${encodeURIComponent(token)}`; const wsUrl = `${wsBase}/ws/terminal?serverId=${encodeURIComponent(selectedServerId)}&token=${encodeURIComponent(token)}`;
@ -181,7 +184,7 @@ export default function TerminalPage() {
ws.onopen = () => { ws.onopen = () => {
setConnectionStatus('connected'); setConnectionStatus('connected');
addLine('system', `Connected to ${hostname}. Type commands below.`); addLine('system', t('messages.connectedTo', { hostname }));
inputRef.current?.focus(); inputRef.current?.focus();
}; };
@ -192,7 +195,7 @@ export default function TerminalPage() {
if (parsed.type === 'output') { if (parsed.type === 'output') {
text = parsed.data ?? ''; text = parsed.data ?? '';
} else if (parsed.type === 'error') { } else if (parsed.type === 'error') {
addLine('error', parsed.data ?? parsed.message ?? 'Unknown error'); addLine('error', parsed.data ?? parsed.message ?? t('messages.unknownError'));
return; return;
} else { } else {
text = parsed.data ?? event.data; text = parsed.data ?? event.data;
@ -207,16 +210,16 @@ export default function TerminalPage() {
}; };
ws.onerror = () => { ws.onerror = () => {
addLine('error', 'WebSocket error occurred.'); addLine('error', t('messages.wsError'));
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
setConnectionStatus('disconnected'); setConnectionStatus('disconnected');
const reason = event.reason ? ` Reason: ${event.reason}` : ''; const reason = event.reason ? ` Reason: ${event.reason}` : '';
addLine('system', `Disconnected from ${hostname} (code ${event.code}).${reason}`); addLine('system', t('messages.disconnectedFrom', { hostname, code: event.code, reason }));
wsRef.current = null; wsRef.current = null;
}; };
}, [selectedServerId, servers, addLine]); }, [selectedServerId, servers, addLine, t]);
// ---- Disconnect ---- // ---- Disconnect ----
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
@ -231,7 +234,7 @@ export default function TerminalPage() {
const sendCommand = useCallback( const sendCommand = useCallback(
(command: string) => { (command: string) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
addLine('error', 'Not connected. Please connect to a server first.'); addLine('error', t('messages.notConnected'));
return; return;
} }
addLine('input', command); addLine('input', command);
@ -245,7 +248,7 @@ export default function TerminalPage() {
}); });
setHistoryIndex(-1); setHistoryIndex(-1);
}, },
[addLine], [addLine, t],
); );
// ---- Handle input key events ---- // ---- Handle input key events ----
@ -299,9 +302,9 @@ export default function TerminalPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Terminal</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Remote shell access {t('subtitle')}
</p> </p>
</div> </div>
<StatusDot status={connectionStatus} /> <StatusDot status={connectionStatus} />
@ -312,7 +315,7 @@ export default function TerminalPage() {
{/* Server selector */} {/* Server selector */}
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<label htmlFor="server-select" className="text-sm font-medium whitespace-nowrap"> <label htmlFor="server-select" className="text-sm font-medium whitespace-nowrap">
Server: {t('server.label')}
</label> </label>
<select <select
id="server-select" id="server-select"
@ -325,7 +328,7 @@ export default function TerminalPage() {
)} )}
> >
<option value=""> <option value="">
{serversLoading ? 'Loading servers...' : 'Select a server'} {serversLoading ? t('server.loadingServers') : t('server.selectServer')}
</option> </option>
{servers.map((server) => ( {servers.map((server) => (
<option key={server.id} value={server.id}> <option key={server.id} value={server.id}>
@ -348,7 +351,7 @@ export default function TerminalPage() {
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
)} )}
> >
Connect {t('actions.connect')}
</button> </button>
)} )}
{(connectionStatus === 'connected' || connectionStatus === 'connecting') && ( {(connectionStatus === 'connected' || connectionStatus === 'connecting') && (
@ -359,15 +362,15 @@ export default function TerminalPage() {
'bg-destructive text-destructive-foreground hover:bg-destructive/90', 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
)} )}
> >
Disconnect {t('actions.disconnect')}
</button> </button>
)} )}
<button <button
onClick={() => setLines([])} onClick={() => setLines([])}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent transition-colors"
title="Clear terminal output" title={t('shortcuts.clearTerminal')}
> >
Clear {t('actions.clear')}
</button> </button>
</div> </div>
</div> </div>
@ -382,8 +385,8 @@ export default function TerminalPage() {
<span className="ml-3 text-xs text-gray-400 font-mono select-none"> <span className="ml-3 text-xs text-gray-400 font-mono select-none">
{selectedServer {selectedServer
? `${selectedServer.hostname} (${selectedServer.host})` ? `${selectedServer.hostname} (${selectedServer.host})`
: 'No server selected'} : t('noServerSelected')}
{connectionStatus === 'connected' && ' - connected'} {connectionStatus === 'connected' && ` - ${t('connection.connected').toLowerCase()}`}
</span> </span>
</div> </div>
@ -395,8 +398,8 @@ export default function TerminalPage() {
{lines.length === 0 && ( {lines.length === 0 && (
<div className="text-gray-600 select-none"> <div className="text-gray-600 select-none">
{connectionStatus === 'connected' {connectionStatus === 'connected'
? 'Terminal ready. Type a command and press Enter.' ? t('readyMessage')
: 'Select a server and click Connect to start a terminal session.'} : t('selectServerPrompt')}
</div> </div>
)} )}
{lines.map((line) => ( {lines.map((line) => (
@ -420,8 +423,8 @@ export default function TerminalPage() {
disabled={connectionStatus !== 'connected'} disabled={connectionStatus !== 'connected'}
placeholder={ placeholder={
connectionStatus === 'connected' connectionStatus === 'connected'
? 'Type a command...' ? t('placeholder')
: 'Connect to a server to start typing' : t('placeholderDisconnected')
} }
className={cn( className={cn(
'bg-transparent text-green-400 font-mono text-sm flex-1 outline-none', 'bg-transparent text-green-400 font-mono text-sm flex-1 outline-none',
@ -436,13 +439,13 @@ export default function TerminalPage() {
{/* Keyboard shortcuts hint */} {/* Keyboard shortcuts hint */}
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span> <span>
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Enter</kbd> Send command <kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Enter</kbd> {t('shortcuts.sendCommand')}
</span> </span>
<span> <span>
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Up/Down</kbd> Command history <kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Up/Down</kbd> {t('shortcuts.commandHistory')}
</span> </span>
<span> <span>
<kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Ctrl+L</kbd> Clear terminal <kbd className="px-1.5 py-0.5 rounded bg-muted text-[10px] font-mono">Ctrl+L</kbd> {t('shortcuts.clearTerminal')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -44,21 +45,6 @@ interface EditFormData {
status: 'active' | 'disabled'; status: 'active' | 'disabled';
} }
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROLES: { value: UserDetail['role']; label: string }[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'operator', label: 'Operator' },
{ value: 'viewer', label: 'Viewer' },
];
const STATUSES: { value: UserDetail['status']; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'disabled', label: 'Disabled' },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -211,20 +197,22 @@ function DeleteDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete User</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Are you sure you want to delete <strong>{userName}</strong>? This action cannot be {t('deleteDialog.message')}
undone.
</p> </p>
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground"> <div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
The user will lose all access and their sessions will be terminated immediately. {t('deleteDialog.warning')}
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@ -233,14 +221,14 @@ function DeleteDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -267,33 +255,35 @@ function ResetPasswordDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Reset Password</h2> <h2 className="text-lg font-semibold mb-2">{t('detail.resetPasswordDialog.title')}</h2>
{success ? ( {success ? (
<> <>
<div className="p-3 mb-4 rounded-md border border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400 text-sm"> <div className="p-3 mb-4 rounded-md border border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400 text-sm">
A password reset link has been sent to <strong>{userName}</strong>&apos;s email address. {t('detail.resetPasswordDialog.success')}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
> >
Done {t('detail.resetPasswordDialog.done')}
</button> </button>
</div> </div>
</> </>
) : ( ) : (
<> <>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
This will send a password reset link to <strong>{userName}</strong>&apos;s email {t('detail.resetPasswordDialog.message')}
address. The user will need to set a new password.
</p> </p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
@ -301,14 +291,14 @@ function ResetPasswordDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={resetting} disabled={resetting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={resetting} disabled={resetting}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{resetting ? 'Sending...' : 'Send Reset Link'} {resetting ? t('detail.resetPasswordDialog.sending') : t('detail.resetPasswordDialog.sendResetLink')}
</button> </button>
</div> </div>
</> </>
@ -323,11 +313,24 @@ function ResetPasswordDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function UserDetailPage() { export default function UserDetailPage() {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const id = params.id as string; const id = params.id as string;
const ROLES: { value: UserDetail['role']; label: string }[] = [
{ value: 'admin', label: t('roles.admin') },
{ value: 'operator', label: t('roles.operator') },
{ value: 'viewer', label: t('roles.viewer') },
];
const STATUSES: { value: UserDetail['status']; label: string }[] = [
{ value: 'active', label: t('statuses.active') },
{ value: 'disabled', label: t('statuses.disabled') },
];
// State ---------------------------------------------------------------- // State ----------------------------------------------------------------
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@ -424,10 +427,10 @@ export default function UserDetailPage() {
const validate = useCallback((): boolean => { const validate = useCallback((): boolean => {
const next: Partial<Record<keyof EditFormData, string>> = {}; const next: Partial<Record<keyof EditFormData, string>> = {};
if (!form.displayName.trim()) next.displayName = 'Display name is required'; if (!form.displayName.trim()) next.displayName = t('validation.displayNameRequired');
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form, t]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (!validate()) return; if (!validate()) return;
@ -458,10 +461,10 @@ export default function UserDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Users {t('detail.backToUsers')}
</button> </button>
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading user details... {t('detail.loading')}
</div> </div>
</div> </div>
); );
@ -479,10 +482,10 @@ export default function UserDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Users {t('detail.backToUsers')}
</button> </button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load user: {(error as Error).message} {t('detail.loadError')} {(error as Error).message}
</div> </div>
</div> </div>
); );
@ -501,7 +504,7 @@ export default function UserDetailPage() {
<path d="M19 12H5" /> <path d="M19 12H5" />
<path d="m12 19-7-7 7-7" /> <path d="m12 19-7-7 7-7" />
</svg> </svg>
Back to Users {t('detail.backToUsers')}
</button> </button>
{/* Page header */} {/* Page header */}
@ -518,13 +521,13 @@ export default function UserDetailPage() {
{/* User Information Card */} {/* User Information Card */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">User Information</h2> <h2 className="text-lg font-semibold">{t('detail.userInformation')}</h2>
{!isEditing && ( {!isEditing && (
<button <button
onClick={startEditing} onClick={startEditing}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
)} )}
</div> </div>
@ -535,7 +538,7 @@ export default function UserDetailPage() {
{/* displayName */} {/* displayName */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Display Name <span className="text-destructive">*</span> {t('form.displayName')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -554,7 +557,7 @@ export default function UserDetailPage() {
{/* role */} {/* role */}
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<select <select
value={form.role} value={form.role}
onChange={(e) => handleChange('role', e.target.value)} onChange={(e) => handleChange('role', e.target.value)}
@ -570,7 +573,7 @@ export default function UserDetailPage() {
{/* status */} {/* status */}
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">{tc('status')}</label>
<select <select
value={form.status} value={form.status}
onChange={(e) => handleChange('status', e.target.value)} onChange={(e) => handleChange('status', e.target.value)}
@ -587,7 +590,7 @@ export default function UserDetailPage() {
{/* Update error */} {/* Update error */}
{updateMutation.isError && ( {updateMutation.isError && (
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to update user: {(updateMutation.error as Error).message} {t('detail.failedToUpdate')} {(updateMutation.error as Error).message}
</div> </div>
)} )}
@ -599,7 +602,7 @@ export default function UserDetailPage() {
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -607,43 +610,43 @@ export default function UserDetailPage() {
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{updateMutation.isPending ? 'Saving...' : 'Save Changes'} {updateMutation.isPending ? tc('saving') : tc('save')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
/* ---------- Read-only info display ---------- */ /* ---------- Read-only info display ---------- */
<dl className="divide-y"> <dl className="divide-y">
<InfoRow label="Display Name" value={user.displayName} /> <InfoRow label={t('form.displayName')} value={user.displayName} />
<InfoRow label="Email" value={user.email} /> <InfoRow label={t('form.email')} value={user.email} />
<InfoRow label="Role" value={<RoleBadge role={user.role} />} /> <InfoRow label={t('form.role')} value={<RoleBadge role={user.role} />} />
<InfoRow label="Status" value={<StatusBadge status={user.status} />} /> <InfoRow label={tc('status')} value={<StatusBadge status={user.status} />} />
<InfoRow label="Tenant" value={user.tenantName || '--'} /> <InfoRow label={t('table.tenant')} value={user.tenantName || '--'} />
<InfoRow label="Last Login" value={formatDateTime(user.lastLoginAt)} /> <InfoRow label={t('table.lastLogin')} value={formatDateTime(user.lastLoginAt)} />
<InfoRow label="Created" value={formatDateTime(user.createdAt)} /> <InfoRow label={tc('created')} value={formatDateTime(user.createdAt)} />
<InfoRow label="Updated" value={formatDateTime(user.updatedAt)} /> <InfoRow label={tc('updated')} value={formatDateTime(user.updatedAt)} />
</dl> </dl>
)} )}
</div> </div>
{/* Activity Log */} {/* Activity Log */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Activity Log</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.activityLog')}</h2>
{activityLog.length === 0 ? ( {activityLog.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> <p className="text-sm text-muted-foreground py-4 text-center">
No activity recorded yet. {t('detail.noActivity')}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Action</th> <th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.action')}</th>
<th className="text-left px-3 py-2 font-medium">Resource</th> <th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.resource')}</th>
<th className="text-left px-3 py-2 font-medium">Details</th> <th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.details')}</th>
<th className="text-left px-3 py-2 font-medium">IP Address</th> <th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.ipAddress')}</th>
<th className="text-right px-3 py-2 font-medium">Time</th> <th className="text-right px-3 py-2 font-medium">{t('detail.activityTable.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -680,7 +683,7 @@ export default function UserDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Quick Actions */} {/* Quick Actions */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.quickActions')}</h2>
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={startEditing} onClick={startEditing}
@ -690,7 +693,7 @@ export default function UserDetailPage() {
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" /> <path d="m15 5 4 4" />
</svg> </svg>
Edit User {t('detail.editUser')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@ -703,7 +706,7 @@ export default function UserDetailPage() {
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
Reset Password {t('detail.resetPassword')}
</button> </button>
<button <button
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
@ -716,36 +719,36 @@ export default function UserDetailPage() {
<line x1="10" x2="10" y1="11" y2="17" /> <line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" /> <line x1="14" x2="14" y1="11" y2="17" />
</svg> </svg>
Delete User {t('detail.deleteUser')}
</button> </button>
</div> </div>
</div> </div>
{/* Account Summary */} {/* Account Summary */}
<div className="bg-card border rounded-lg p-6"> <div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Account Summary</h2> <h2 className="text-lg font-semibold mb-4">{t('detail.accountSummary')}</h2>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="text-sm text-muted-foreground mb-1">Status</p> <p className="text-sm text-muted-foreground mb-1">{tc('status')}</p>
<StatusBadge status={user.status} /> <StatusBadge status={user.status} />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground mb-1">Role</p> <p className="text-sm text-muted-foreground mb-1">{tc('role')}</p>
<RoleBadge role={user.role} /> <RoleBadge role={user.role} />
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground mb-1">Last Login</p> <p className="text-sm text-muted-foreground mb-1">{t('detail.lastLogin')}</p>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'Never'} {user.lastLoginAt ? formatDateTime(user.lastLoginAt) : tc('never')}
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground mb-1">Member Since</p> <p className="text-sm text-muted-foreground mb-1">{t('detail.memberSince')}</p>
<p className="text-sm font-medium">{formatDate(user.createdAt)}</p> <p className="text-sm font-medium">{formatDate(user.createdAt)}</p>
</div> </div>
{user.tenantName && ( {user.tenantName && (
<div> <div>
<p className="text-sm text-muted-foreground mb-1">Tenant</p> <p className="text-sm text-muted-foreground mb-1">{t('table.tenant')}</p>
<p className="text-sm font-medium">{user.tenantName}</p> <p className="text-sm font-medium">{user.tenantName}</p>
</div> </div>
)} )}

View File

@ -3,6 +3,7 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys'; import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -45,17 +46,6 @@ interface EditUserFormData {
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ROLES: { value: User['role']; label: string }[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'operator', label: 'Operator' },
{ value: 'viewer', label: 'Viewer' },
];
const STATUSES: { value: User['status']; label: string }[] = [
{ value: 'active', label: 'Active' },
{ value: 'disabled', label: 'Disabled' },
];
const EMPTY_FORM: UserFormData = { const EMPTY_FORM: UserFormData = {
displayName: '', displayName: '',
email: '', email: '',
@ -167,20 +157,29 @@ function AddUserDialog({
onChange: (field: keyof UserFormData, value: string) => void; onChange: (field: keyof UserFormData, value: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
const ROLES: { value: User['role']; label: string }[] = [
{ value: 'admin', label: t('roles.admin') },
{ value: 'operator', label: t('roles.operator') },
{ value: 'viewer', label: t('roles.viewer') },
];
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4">Add User</h2> <h2 className="text-lg font-semibold mb-4">{t('createDialog.title')}</h2>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1"> <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
{/* displayName */} {/* displayName */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Display Name <span className="text-destructive">*</span> {t('form.displayName')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -200,7 +199,7 @@ function AddUserDialog({
{/* email */} {/* email */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Email <span className="text-destructive">*</span> {t('form.email')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="email" type="email"
@ -220,7 +219,7 @@ function AddUserDialog({
{/* password */} {/* password */}
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Password <span className="text-destructive">*</span> {t('form.password')} <span className="text-destructive">*</span>
</label> </label>
<input <input
type="password" type="password"
@ -239,7 +238,7 @@ function AddUserDialog({
{/* role */} {/* role */}
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<select <select
value={form.role} value={form.role}
onChange={(e) => onChange('role', e.target.value)} onChange={(e) => onChange('role', e.target.value)}
@ -255,7 +254,7 @@ function AddUserDialog({
{/* tenantId */} {/* tenantId */}
<div> <div>
<label className="block text-sm font-medium mb-1">Tenant ID</label> <label className="block text-sm font-medium mb-1">{t('form.tenantId')}</label>
<input <input
type="text" type="text"
value={form.tenantId} value={form.tenantId}
@ -273,7 +272,7 @@ function AddUserDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -281,7 +280,7 @@ function AddUserDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Creating...' : 'Create User'} {saving ? t('createDialog.creating') : t('createDialog.create')}
</button> </button>
</div> </div>
</div> </div>
@ -310,21 +309,35 @@ function EditUserDialog({
onChange: (field: keyof EditUserFormData, value: string) => void; onChange: (field: keyof EditUserFormData, value: string) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
const ROLES: { value: User['role']; label: string }[] = [
{ value: 'admin', label: t('roles.admin') },
{ value: 'operator', label: t('roles.operator') },
{ value: 'viewer', label: t('roles.viewer') },
];
const STATUSES: { value: User['status']; label: string }[] = [
{ value: 'active', label: t('statuses.active') },
{ value: 'disabled', label: t('statuses.disabled') },
];
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-4"> <h2 className="text-lg font-semibold mb-4">
Edit User: <span className="text-muted-foreground">{userName}</span> {t('editDialog.title')} <span className="text-muted-foreground">{userName}</span>
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
{/* role */} {/* role */}
<div> <div>
<label className="block text-sm font-medium mb-1">Role</label> <label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<select <select
value={form.role} value={form.role}
onChange={(e) => onChange('role', e.target.value)} onChange={(e) => onChange('role', e.target.value)}
@ -340,7 +353,7 @@ function EditUserDialog({
{/* status */} {/* status */}
<div> <div>
<label className="block text-sm font-medium mb-1">Status</label> <label className="block text-sm font-medium mb-1">{tc('status')}</label>
<select <select
value={form.status} value={form.status}
onChange={(e) => onChange('status', e.target.value)} onChange={(e) => onChange('status', e.target.value)}
@ -362,7 +375,7 @@ function EditUserDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={saving} disabled={saving}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
type="button" type="button"
@ -370,7 +383,7 @@ function EditUserDialog({
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? t('editDialog.saving') : t('editDialog.save')}
</button> </button>
</div> </div>
</div> </div>
@ -395,20 +408,22 @@ function DeleteUserDialog({
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
}) { }) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} /> <div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4"> <div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">Delete User</h2> <h2 className="text-lg font-semibold mb-2">{t('deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Are you sure you want to delete <strong>{userName}</strong>? This action cannot be {t('deleteDialog.message')}
undone.
</p> </p>
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground"> <div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
The user will lose all access and their sessions will be terminated immediately. {t('deleteDialog.warning')}
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@ -417,14 +432,14 @@ function DeleteUserDialog({
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent" className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting} disabled={deleting}
> >
Cancel {tc('cancel')}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete'} {deleting ? tc('deleting') : tc('delete')}
</button> </button>
</div> </div>
</div> </div>
@ -437,6 +452,8 @@ function DeleteUserDialog({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function UsersPage() { export default function UsersPage() {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
@ -453,6 +470,17 @@ export default function UsersPage() {
const [roleFilter, setRoleFilter] = useState<string>('all'); const [roleFilter, setRoleFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const ROLES: { value: User['role']; label: string }[] = [
{ value: 'admin', label: t('roles.admin') },
{ value: 'operator', label: t('roles.operator') },
{ value: 'viewer', label: t('roles.viewer') },
];
const STATUSES: { value: User['status']; label: string }[] = [
{ value: 'active', label: t('statuses.active') },
{ value: 'disabled', label: t('statuses.disabled') },
];
// Queries -------------------------------------------------------------- // Queries --------------------------------------------------------------
const { data: usersData, isLoading, error } = useQuery({ const { data: usersData, isLoading, error } = useQuery({
queryKey: queryKeys.users.list(), queryKey: queryKeys.users.list(),
@ -516,14 +544,14 @@ export default function UsersPage() {
// Helpers -------------------------------------------------------------- // Helpers --------------------------------------------------------------
const validateAdd = useCallback((): boolean => { const validateAdd = useCallback((): boolean => {
const next: Partial<Record<keyof UserFormData, string>> = {}; const next: Partial<Record<keyof UserFormData, string>> = {};
if (!form.displayName.trim()) next.displayName = 'Display name is required'; if (!form.displayName.trim()) next.displayName = t('validation.displayNameRequired');
if (!form.email.trim()) next.email = 'Email is required'; if (!form.email.trim()) next.email = t('validation.emailRequired');
if (!form.password.trim()) next.password = 'Password is required'; if (!form.password.trim()) next.password = t('validation.passwordRequired');
if (form.password.trim() && form.password.trim().length < 8) if (form.password.trim() && form.password.trim().length < 8)
next.password = 'Password must be at least 8 characters'; next.password = 'Password must be at least 8 characters';
setErrors(next); setErrors(next);
return Object.keys(next).length === 0; return Object.keys(next).length === 0;
}, [form]); }, [form, t]);
const closeAddDialog = useCallback(() => { const closeAddDialog = useCallback(() => {
setAddDialogOpen(false); setAddDialogOpen(false);
@ -598,16 +626,16 @@ export default function UsersPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div> <div>
<h1 className="text-2xl font-bold">Users</h1> <h1 className="text-2xl font-bold">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage user accounts and access {t('subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={openAdd} onClick={openAdd}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap" className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
> >
Add User {t('addUser')}
</button> </button>
</div> </div>
@ -620,7 +648,7 @@ export default function UsersPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
placeholder="Search by name or email..." placeholder={t('searchPlaceholder')}
/> />
</div> </div>
@ -635,7 +663,7 @@ export default function UsersPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
All Roles {t('allRoles')}
</button> </button>
{ROLES.map((r) => ( {ROLES.map((r) => (
<button <button
@ -664,7 +692,7 @@ export default function UsersPage() {
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
All {tc('all')}
</button> </button>
{STATUSES.map((s) => ( {STATUSES.map((s) => (
<button <button
@ -686,14 +714,14 @@ export default function UsersPage() {
{/* Error state */} {/* Error state */}
{error && ( {error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm"> <div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
Failed to load users: {(error as Error).message} {t('loadError')} {(error as Error).message}
</div> </div>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center"> <div className="text-sm text-muted-foreground py-12 text-center">
Loading users... {t('loading')}
</div> </div>
)} )}
@ -704,14 +732,14 @@ export default function UsersPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Name</th> <th className="text-left px-4 py-3 font-medium">{t('table.name')}</th>
<th className="text-left px-4 py-3 font-medium">Email</th> <th className="text-left px-4 py-3 font-medium">{t('table.email')}</th>
<th className="text-left px-4 py-3 font-medium">Role</th> <th className="text-left px-4 py-3 font-medium">{t('table.role')}</th>
<th className="text-left px-4 py-3 font-medium">Tenant</th> <th className="text-left px-4 py-3 font-medium">{t('table.tenant')}</th>
<th className="text-left px-4 py-3 font-medium">Status</th> <th className="text-left px-4 py-3 font-medium">{t('table.status')}</th>
<th className="text-left px-4 py-3 font-medium">Last Login</th> <th className="text-left px-4 py-3 font-medium">{t('table.lastLogin')}</th>
<th className="text-left px-4 py-3 font-medium">Created</th> <th className="text-left px-4 py-3 font-medium">{t('table.created')}</th>
<th className="text-right px-4 py-3 font-medium">Actions</th> <th className="text-right px-4 py-3 font-medium">{t('table.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -722,8 +750,8 @@ export default function UsersPage() {
className="text-center text-muted-foreground py-12" className="text-center text-muted-foreground py-12"
> >
{allUsers.length === 0 {allUsers.length === 0
? 'No users found. Add one to get started.' ? t('empty')
: 'No users match the current filters.'} : t('noMatchingFilters')}
</td> </td>
</tr> </tr>
) : ( ) : (
@ -764,19 +792,19 @@ export default function UsersPage() {
onClick={() => router.push(`/users/${user.id}`)} onClick={() => router.push(`/users/${user.id}`)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
View {tc('view')}
</button> </button>
<button <button
onClick={() => openEdit(user)} onClick={() => openEdit(user)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors" className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
> >
Edit {tc('edit')}
</button> </button>
<button <button
onClick={() => setDeleteTarget(user)} onClick={() => setDeleteTarget(user)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors" className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
> >
Delete {tc('delete')}
</button> </button>
</div> </div>
</td> </td>
@ -792,7 +820,7 @@ export default function UsersPage() {
{/* Results count */} {/* Results count */}
{!isLoading && !error && allUsers.length > 0 && ( {!isLoading && !error && allUsers.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
Showing {filteredUsers.length} of {allUsers.length} user{allUsers.length !== 1 ? 's' : ''} {t('showing', { count: filteredUsers.length, total: allUsers.length })}
</div> </div>
)} )}

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
interface InviteInfo { interface InviteInfo {
@ -22,6 +23,8 @@ export default function AcceptInvitePage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const token = params.token as string; const token = params.token as string;
const { t } = useTranslation('auth');
const { t: tc } = useTranslation('common');
const [invite, setInvite] = useState<InviteInfo | null>(null); const [invite, setInvite] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -39,22 +42,18 @@ export default function AcceptInvitePage() {
const data = await apiClient<InviteInfo>(`/api/v1/auth/invite/${token}`); const data = await apiClient<InviteInfo>(`/api/v1/auth/invite/${token}`);
setInvite(data); setInvite(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Invalid invitation'); setError(err instanceof Error ? err.message : t('inviteInvalid'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
validateInvite(); validateInvite();
}, [token]); }, [token, t]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password !== confirmPassword) { if (password !== confirmPassword) {
setSubmitError('Passwords do not match'); setSubmitError(tc('passwordsNoMatch'));
return;
}
if (password.length < 6) {
setSubmitError('Password must be at least 6 characters');
return; return;
} }
@ -89,7 +88,7 @@ export default function AcceptInvitePage() {
if (loading) { if (loading) {
return ( return (
<div className="w-full max-w-md p-8 bg-card rounded-lg border text-center"> <div className="w-full max-w-md p-8 bg-card rounded-lg border text-center">
<p className="text-muted-foreground">Validating invitation...</p> <p className="text-muted-foreground">{t('validatingInvite')}</p>
</div> </div>
); );
} }
@ -97,13 +96,13 @@ export default function AcceptInvitePage() {
if (error) { if (error) {
return ( return (
<div className="w-full max-w-md p-8 bg-card rounded-lg border text-center space-y-4"> <div className="w-full max-w-md p-8 bg-card rounded-lg border text-center space-y-4">
<h1 className="text-xl font-bold text-red-500">Invalid Invitation</h1> <h1 className="text-xl font-bold text-red-500">{t('inviteInvalid')}</h1>
<p className="text-muted-foreground">{error}</p> <p className="text-muted-foreground">{error}</p>
<Link <Link
href="/login" href="/login"
className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90" className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm hover:opacity-90"
> >
Go to Login {t('backToLogin')}
</Link> </Link>
</div> </div>
); );
@ -112,58 +111,56 @@ export default function AcceptInvitePage() {
return ( return (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border"> <div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold">IT0</h1> <h1 className="text-3xl font-bold">{t('appTitle')}</h1>
<p className="text-muted-foreground mt-2">Accept Invitation</p> <p className="text-muted-foreground mt-2">{t('inviteTitle')}</p>
</div> </div>
<div className="bg-muted/50 rounded-md p-4 space-y-1 text-sm"> <div className="bg-muted/50 rounded-md p-4 space-y-1 text-sm">
<p> <p>
<span className="text-muted-foreground">Organization:</span>{' '} <span className="text-muted-foreground">{t('organizationName')}:</span>{' '}
<span className="font-medium">{invite?.tenantName}</span> <span className="font-medium">{invite?.tenantName}</span>
</p> </p>
<p> <p>
<span className="text-muted-foreground">Email:</span>{' '} <span className="text-muted-foreground">{t('inviteEmail')}</span>{' '}
<span className="font-medium">{invite?.email}</span> <span className="font-medium">{invite?.email}</span>
</p> </p>
<p> <p>
<span className="text-muted-foreground">Role:</span>{' '} <span className="text-muted-foreground">{t('inviteRole')}</span>{' '}
<span className="font-medium capitalize">{invite?.role}</span> <span className="font-medium capitalize">{invite?.role}</span>
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Full Name</label> <label className="block text-sm font-medium mb-1">{t('fullName')}</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="John Doe" placeholder={t('fullNamePlaceholder')}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Password</label> <label className="block text-sm font-medium mb-1">{t('password')}</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="At least 6 characters"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Confirm Password</label> <label className="block text-sm font-medium mb-1">{t('confirmPassword')}</label>
<input <input
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Re-enter your password"
required required
/> />
</div> </div>
@ -177,14 +174,14 @@ export default function AcceptInvitePage() {
disabled={isSubmitting} disabled={isSubmitting}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium" className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
> >
{isSubmitting ? 'Joining...' : 'Join Organization'} {isSubmitting ? t('joining') : t('joinOrganization')}
</button> </button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Already have an account?{' '} {t('haveAccount')}{' '}
<Link href="/login" className="text-primary hover:underline"> <Link href="/login" className="text-primary hover:underline">
Sign in {t('signInLink')}
</Link> </Link>
</p> </p>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
interface LoginResponse { interface LoginResponse {
@ -13,6 +14,7 @@ interface LoginResponse {
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation('auth');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -33,7 +35,6 @@ export default function LoginPage() {
localStorage.setItem('refresh_token', data.refreshToken); localStorage.setItem('refresh_token', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
// Decode tenantId from JWT and store as current tenant
try { try {
const payload = JSON.parse(atob(data.accessToken.split('.')[1])); const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
if (payload.tenantId) { if (payload.tenantId) {
@ -43,7 +44,7 @@ export default function LoginPage() {
router.push('/dashboard'); router.push('/dashboard');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : t('loginFailed'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -52,23 +53,23 @@ export default function LoginPage() {
return ( return (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border"> <div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold">IT0</h1> <h1 className="text-3xl font-bold">{t('appTitle')}</h1>
<p className="text-muted-foreground mt-2">Admin Console</p> <p className="text-muted-foreground mt-2">{t('adminConsole')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <label className="block text-sm font-medium mb-1">{t('email')}</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md" className="w-full px-3 py-2 bg-input border rounded-md"
placeholder="admin@example.com" placeholder={t('emailPlaceholder')}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Password</label> <label className="block text-sm font-medium mb-1">{t('password')}</label>
<input <input
type="password" type="password"
value={password} value={password}
@ -85,14 +86,14 @@ export default function LoginPage() {
disabled={isLoading} disabled={isLoading}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50" className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
> >
{isLoading ? 'Signing in...' : 'Sign In'} {isLoading ? t('signingIn') : t('signIn')}
</button> </button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '} {t('noAccount')}{' '}
<Link href="/register" className="text-primary hover:underline"> <Link href="/register" className="text-primary hover:underline">
Create one {t('createOne')}
</Link> </Link>
</p> </p>
</div> </div>

View File

@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client'; import { apiClient } from '@/infrastructure/api/api-client';
interface RegisterResponse { interface RegisterResponse {
@ -13,6 +14,8 @@ interface RegisterResponse {
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation('auth');
const { t: tc } = useTranslation('common');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -24,11 +27,11 @@ export default function RegisterPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match'); setError(tc('passwordsNoMatch'));
return; return;
} }
if (password.length < 6) { if (password.length < 6) {
setError('Password must be at least 6 characters'); setError(t('passwordMinLength', 'Password must be at least 6 characters'));
return; return;
} }
@ -68,72 +71,70 @@ export default function RegisterPage() {
return ( return (
<div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border"> <div className="w-full max-w-md p-8 space-y-6 bg-card rounded-lg border">
<div className="text-center"> <div className="text-center">
<h1 className="text-3xl font-bold">IT0</h1> <h1 className="text-3xl font-bold">{t('appTitle')}</h1>
<p className="text-muted-foreground mt-2">Create your account</p> <p className="text-muted-foreground mt-2">{t('createAccount')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1">Full Name</label> <label className="block text-sm font-medium mb-1">{t('fullName')}</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="John Doe" placeholder={t('fullNamePlaceholder')}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Email</label> <label className="block text-sm font-medium mb-1">{t('email')}</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="you@example.com" placeholder={t('emailPlaceholder')}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-1">
Organization Name {t('organizationName')}
<span className="text-muted-foreground font-normal ml-1">(optional)</span> <span className="text-muted-foreground font-normal ml-1">({tc('optional')})</span>
</label> </label>
<input <input
type="text" type="text"
value={companyName} value={companyName}
onChange={(e) => setCompanyName(e.target.value)} onChange={(e) => setCompanyName(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Acme Corp" placeholder={t('organizationNamePlaceholder')}
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Provide a name to create a new organization. Leave blank to join as a viewer. {t('organizationHint')}
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Password</label> <label className="block text-sm font-medium mb-1">{t('password')}</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="At least 6 characters"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1">Confirm Password</label> <label className="block text-sm font-medium mb-1">{t('confirmPassword')}</label>
<input <input
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-input border rounded-md text-sm" className="w-full px-3 py-2 bg-input border rounded-md text-sm"
placeholder="Re-enter your password"
required required
/> />
</div> </div>
@ -147,14 +148,14 @@ export default function RegisterPage() {
disabled={isLoading} disabled={isLoading}
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium" className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 text-sm font-medium"
> >
{isLoading ? 'Creating account...' : 'Create Account'} {isLoading ? t('creatingAccount') : t('register')}
</button> </button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Already have an account?{' '} {t('haveAccount')}{' '}
<Link href="/login" className="text-primary hover:underline"> <Link href="/login" className="text-primary hover:underline">
Sign in {t('signInLink')}
</Link> </Link>
</p> </p>
</div> </div>

View File

@ -4,6 +4,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Provider as ReduxProvider } from 'react-redux'; import { Provider as ReduxProvider } from 'react-redux';
import { useState } from 'react'; import { useState } from 'react';
import { createStore } from '@/stores/redux/store'; import { createStore } from '@/stores/redux/store';
import '@/i18n/config';
import { syncLocaleOnLoad } from '@/stores/zustand/locale-store';
// Sync persisted locale with i18next on app load
syncLocaleOnLoad();
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({ const [queryClient] = useState(() => new QueryClient({

View File

@ -0,0 +1,107 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// English
import enCommon from './locales/en/common.json';
import enSidebar from './locales/en/sidebar.json';
import enTopbar from './locales/en/topbar.json';
import enAuth from './locales/en/auth.json';
import enSettings from './locales/en/settings.json';
import enDashboard from './locales/en/dashboard.json';
import enServers from './locales/en/servers.json';
import enAgentConfig from './locales/en/agent-config.json';
import enSecurity from './locales/en/security.json';
import enMonitoring from './locales/en/monitoring.json';
import enAudit from './locales/en/audit.json';
import enCommunication from './locales/en/communication.json';
import enTenants from './locales/en/tenants.json';
import enUsers from './locales/en/users.json';
import enTerminal from './locales/en/terminal.json';
import enStandingOrders from './locales/en/standing-orders.json';
import enRunbooks from './locales/en/runbooks.json';
import enSessions from './locales/en/sessions.json';
// Chinese
import zhCommon from './locales/zh/common.json';
import zhSidebar from './locales/zh/sidebar.json';
import zhTopbar from './locales/zh/topbar.json';
import zhAuth from './locales/zh/auth.json';
import zhSettings from './locales/zh/settings.json';
import zhDashboard from './locales/zh/dashboard.json';
import zhServers from './locales/zh/servers.json';
import zhAgentConfig from './locales/zh/agent-config.json';
import zhSecurity from './locales/zh/security.json';
import zhMonitoring from './locales/zh/monitoring.json';
import zhAudit from './locales/zh/audit.json';
import zhCommunication from './locales/zh/communication.json';
import zhTenants from './locales/zh/tenants.json';
import zhUsers from './locales/zh/users.json';
import zhTerminal from './locales/zh/terminal.json';
import zhStandingOrders from './locales/zh/standing-orders.json';
import zhRunbooks from './locales/zh/runbooks.json';
import zhSessions from './locales/zh/sessions.json';
export const supportedLngs = ['en', 'zh'] as const;
export type SupportedLocale = (typeof supportedLngs)[number];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: {
common: enCommon,
sidebar: enSidebar,
topbar: enTopbar,
auth: enAuth,
settings: enSettings,
dashboard: enDashboard,
servers: enServers,
'agent-config': enAgentConfig,
security: enSecurity,
monitoring: enMonitoring,
audit: enAudit,
communication: enCommunication,
tenants: enTenants,
users: enUsers,
terminal: enTerminal,
'standing-orders': enStandingOrders,
runbooks: enRunbooks,
sessions: enSessions,
},
zh: {
common: zhCommon,
sidebar: zhSidebar,
topbar: zhTopbar,
auth: zhAuth,
settings: zhSettings,
dashboard: zhDashboard,
servers: zhServers,
'agent-config': zhAgentConfig,
security: zhSecurity,
monitoring: zhMonitoring,
audit: zhAudit,
communication: zhCommunication,
tenants: zhTenants,
users: zhUsers,
terminal: zhTerminal,
'standing-orders': zhStandingOrders,
runbooks: zhRunbooks,
sessions: zhSessions,
},
},
fallbackLng: 'en',
defaultNS: 'common',
supportedLngs: [...supportedLngs],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'it0-locale',
caches: [],
},
});
export default i18n;

View File

@ -0,0 +1,175 @@
{
"title": "Agent Configuration",
"subtitle": "Manage AI engine settings, system prompts, and allowed tools.",
"loading": "Loading agent configuration...",
"loadError": "Failed to load configuration. Using defaults.",
"saveSuccess": "Configuration saved successfully.",
"saveConfig": "Save Configuration",
"resetDefaults": "Reset to Defaults",
"engine": {
"title": "Engine",
"claudeCli": "Claude CLI",
"claudeCliDesc": "Local Claude Code CLI process",
"claudeApi": "Claude API",
"claudeApiDesc": "Direct Anthropic API integration"
},
"systemPrompt": {
"title": "System Prompt",
"placeholder": "Enter the system prompt for the AI agent..."
},
"maxTurns": {
"title": "Max Turns",
"description": "Maximum number of conversation turns per session."
},
"maxBudget": {
"title": "Max Budget (USD)",
"description": "Maximum token cost per session."
},
"allowedTools": {
"title": "Allowed Tools",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"tools": {
"Bash": "Execute shell commands",
"Read": "Read file contents",
"Write": "Write / create files",
"Edit": "Edit existing files",
"Glob": "Search files by pattern",
"Grep": "Search file contents",
"WebFetch": "Fetch web content",
"WebSearch": "Search the web",
"NotebookEdit": "Edit Jupyter notebooks",
"Task": "Launch sub-agent tasks"
}
},
"sdk": {
"title": "Agent SDK Configuration",
"subtitle": "Configure Claude Agent SDK billing, approval flow, and tool permissions per tenant.",
"loading": "Loading SDK configuration...",
"loadError": "Failed to load configuration. Using defaults.",
"billingMode": {
"title": "Billing Mode",
"subtitle": "Choose how Claude Agent SDK usage is billed for this tenant.",
"subscription": "Subscription",
"subscriptionDesc": "Use operator's Claude login (no API key needed)",
"apiKey": "API Key",
"apiKeyDesc": "Tenant's own Anthropic API key (token-based billing)"
},
"apiKey": {
"label": "Anthropic API Key",
"configured": "Key configured",
"replacePlaceholder": "Enter new key to replace existing",
"placeholder": "sk-ant-...",
"remove": "Remove",
"encryptionNote": "API key is encrypted (AES-256-GCM) before storage. Never stored in plaintext."
},
"approvalTimeout": {
"title": "L2 Approval Timeout",
"subtitle": "For high-risk commands (L2), how long to wait for manual approval before auto-approving. Set to 0 to disable auto-approve (wait indefinitely).",
"unit": "sec",
"autoApproveDisabled": "Auto-approve disabled — commands will wait for manual approval indefinitely.",
"autoApproveAfter": "Commands auto-approved after {{seconds}} seconds without response."
},
"toolPermissions": {
"title": "Tool Permissions",
"subtitle": "Override tool access per tenant. Empty whitelist means use RBAC defaults. Blacklisted tools are always denied regardless of role.",
"tool": "Tool",
"description": "Description",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"whitelistCount": "Whitelist: {{count}}",
"whitelistNone": "Whitelist: none (use RBAC defaults)",
"blacklistCount": "Blacklist: {{count}}",
"blacklistNone": "Blacklist: none"
},
"rbac": {
"title": "RBAC Tool Access (Reference)",
"subtitle": "Default tool access per role (applied when whitelist is empty).",
"admin": "Admin",
"adminTools": "All tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task)",
"operator": "Operator",
"operatorTools": "Bash, Read, Write, Edit, Glob, Grep",
"viewer": "Viewer",
"viewerTools": "Read, Glob, Grep (read-only)"
}
},
"skills": {
"title": "Skills",
"subtitle": "Manage Claude Code skills for the AI agent",
"addSkill": "Add Skill",
"editSkill": "Edit Skill",
"deleteSkill": "Delete Skill",
"loading": "Loading skills...",
"loadError": "Failed to load skills:",
"empty": "No skills configured yet.",
"categories": {
"inspection": "Inspection",
"deployment": "Deployment",
"maintenance": "Maintenance",
"security": "Security",
"monitoring": "Monitoring",
"custom": "Custom"
},
"form": {
"name": "Name",
"description": "Description",
"category": "Category",
"script": "Script",
"tags": "Tags",
"enabled": "Enabled"
},
"detail": {
"backToSkills": "Back to Skills",
"overview": "Overview",
"promptTemplate": "Prompt Template",
"allowedTools": "Allowed Tools",
"configuration": "Configuration",
"quickActions": "Quick Actions",
"metadata": "Metadata",
"duplicateSkill": "Duplicate Skill",
"deleteSkill": "Delete Skill",
"builtIn": "Built-in",
"custom": "Custom",
"enabled": "Enabled",
"disabled": "Disabled",
"noPrompt": "No prompt template defined.",
"noTools": "No tools configured.",
"notFound": "Skill not found.",
"loading": "Loading skill..."
}
},
"hooks": {
"title": "Hook Scripts",
"subtitle": "Lifecycle scripts for agent tool execution",
"addHook": "Add Hook",
"editHook": "Edit Hook",
"deleteHook": "Delete Hook",
"loading": "Loading hooks...",
"loadError": "Failed to load hooks:",
"empty": "No hooks configured.",
"infoBanner": "Hook scripts run before or after agent tool calls. Use them for logging, validation, or custom side-effects.",
"table": {
"name": "Name",
"event": "Event",
"toolPattern": "Tool Pattern",
"script": "Script",
"enabled": "Enabled",
"actions": "Actions"
},
"form": {
"name": "Name",
"eventType": "Event Type",
"toolPattern": "Tool Pattern",
"script": "Script",
"timeout": "Timeout",
"enabled": "Enabled",
"description": "Description"
},
"events": {
"beforeTool": "Before Tool",
"afterTool": "After Tool",
"onError": "On Error",
"onApproval": "On Approval"
}
}
}

View File

@ -0,0 +1,98 @@
{
"logs": {
"title": "Audit Logs",
"subtitle": "View immutable audit trail of all operations and configuration changes.",
"exportCsv": "Export CSV",
"loading": "Loading audit logs...",
"loadError": "Failed to load audit logs:",
"empty": "No audit logs found for the current filters.",
"filters": {
"dateFrom": "Date From",
"dateTo": "Date To",
"actionType": "Action Type",
"actorType": "Actor Type",
"resourceType": "Resource Type"
},
"table": {
"timestamp": "Timestamp",
"action": "Action",
"actorType": "Actor Type",
"actorId": "Actor ID",
"resourceType": "Resource Type",
"resourceId": "Resource ID",
"description": "Description"
},
"pagination": {
"rowsPerPage": "Rows per page",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{current}} of {{total}}"
},
"fullDetail": "Full Detail"
},
"replay": {
"title": "Session Replay",
"subtitle": "Review agent session execution history",
"loading": "Loading sessions...",
"loadError": "Failed to load sessions:",
"empty": "No sessions found for the current filters.",
"showing": "Showing {{count}} of {{total}} sessions",
"filters": {
"dateFrom": "Date From",
"dateTo": "Date To",
"status": "Status",
"search": "Search",
"searchPlaceholder": "Session ID or task description..."
},
"statuses": {
"all": "All",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"running": "Running"
},
"table": {
"sessionId": "Session ID",
"taskDescription": "Task Description",
"status": "Status",
"duration": "Duration",
"commands": "Commands",
"startedAt": "Started At"
},
"panel": {
"title": "Session Replay",
"close": "Close",
"id": "ID:",
"duration": "Duration:",
"commands": "Commands:",
"servers": "Servers:"
},
"playback": {
"play": "Play",
"pause": "Pause",
"reset": "Reset",
"showAll": "Show All",
"speed": "Speed:",
"events": "events",
"eventsProgress": "{{current}} / {{total}} events"
},
"events": {
"loading": "Loading session events...",
"loadError": "Error loading events:",
"empty": "No events recorded for this session.",
"pressPlay": "Press Play to begin the session replay.",
"collapse": "Collapse",
"expandFullOutput": "Expand full output",
"types": {
"commandExecuted": "Command Executed",
"outputReceived": "Output Received",
"approvalRequested": "Approval Requested",
"approvalGranted": "Approval Granted",
"approvalDenied": "Approval Denied",
"error": "Error",
"sessionStarted": "Session Started",
"sessionCompleted": "Session Completed"
}
}
}
}

View File

@ -0,0 +1,35 @@
{
"appTitle": "IT0",
"adminConsole": "Admin Console",
"signIn": "Sign In",
"signingIn": "Signing in...",
"email": "Email",
"password": "Password",
"emailPlaceholder": "admin@example.com",
"noAccount": "Don't have an account?",
"createOne": "Create one",
"haveAccount": "Already have an account?",
"signInLink": "Sign in",
"createAccount": "Create your account",
"createAccountSubtitle": "Set up a new organization to get started.",
"fullName": "Full Name",
"fullNamePlaceholder": "John Doe",
"organizationName": "Organization Name",
"organizationNamePlaceholder": "Acme Corp",
"organizationHint": "A new tenant will be created for your organization.",
"confirmPassword": "Confirm Password",
"creatingAccount": "Creating account...",
"register": "Create Account",
"loginFailed": "Login failed",
"inviteTitle": "Accept Invitation",
"inviteSubtitle": "You've been invited to join",
"inviteRole": "Role:",
"inviteEmail": "Email:",
"yourName": "Your Name",
"choosePassword": "Choose a Password",
"joining": "Joining...",
"joinOrganization": "Join Organization",
"inviteInvalid": "This invitation is invalid or has expired.",
"backToLogin": "Back to Login",
"validatingInvite": "Validating invitation..."
}

View File

@ -0,0 +1,89 @@
{
"save": "Save Changes",
"saving": "Saving...",
"cancel": "Cancel",
"delete": "Delete",
"deleting": "Deleting...",
"edit": "Edit",
"create": "Create",
"creating": "Creating...",
"add": "Add",
"remove": "Remove",
"close": "Close",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"loading": "Loading...",
"search": "Search...",
"noResults": "No results found.",
"actions": "Actions",
"name": "Name",
"type": "Type",
"status": "Status",
"description": "Description",
"created": "Created",
"updated": "Updated",
"createdAt": "Created At",
"updatedAt": "Updated At",
"required": "Required",
"optional": "Optional",
"enabled": "Enabled",
"disabled": "Disabled",
"active": "Active",
"inactive": "Inactive",
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"yes": "Yes",
"no": "No",
"none": "None",
"all": "All",
"id": "ID",
"email": "Email",
"role": "Role",
"roles": "Roles",
"environment": "Environment",
"tags": "Tags",
"priority": "Priority",
"severity": "Severity",
"details": "Details",
"configuration": "Configuration",
"view": "View",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"copy": "Copy",
"copied": "Copied!",
"never": "Never",
"unknown": "Unknown",
"notSelected": "Not selected",
"comingSoon": "Coming soon",
"noData": "No data available.",
"confirmDelete": "Are you sure you want to delete this?",
"operationSuccess": "Operation completed successfully.",
"operationFailed": "Operation failed.",
"passwordsNoMatch": "Passwords do not match.",
"riskLevel": {
"l0": "Read Only",
"l1": "Low Risk",
"l2": "High Risk",
"l3": "Forbidden"
},
"approvalStatus": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"expired": "Expired"
},
"serverStatus": {
"active": "Active",
"inactive": "Inactive",
"maintenance": "Maintenance",
"unreachable": "Unreachable"
}
}

View File

@ -0,0 +1,112 @@
{
"title": "Communication Settings",
"tabs": {
"channels": "Channels",
"contacts": "Contacts",
"escalationPolicies": "Escalation Policies"
},
"channels": {
"title": "Notification Channels",
"subtitle": "Configure how notifications are delivered.",
"types": {
"push": "Push Notification",
"sms": "SMS",
"voice": "Voice Call",
"email": "Email",
"telegram": "Telegram",
"wechatWork": "WeChat Work",
"voiceService": "Voice Service"
},
"configured": "Configured",
"notConfigured": "Not Configured",
"showConfig": "Show Config",
"hideConfig": "Hide Config",
"saveConfiguration": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Channel configuration saved.",
"fields": {
"apiKey": "API Key",
"apiSecret": "API Secret",
"fromNumber": "From Number",
"webhookUrl": "Webhook URL",
"botToken": "Bot Token",
"chatId": "Chat ID",
"corpId": "Corp ID",
"agentId": "Agent ID",
"secret": "Secret",
"smtpHost": "SMTP Host",
"smtpPort": "SMTP Port",
"username": "Username",
"password": "Password",
"fromAddress": "From Address",
"endpoint": "Endpoint",
"enabled": "Enabled",
"provider": "Provider",
"appId": "App ID"
}
},
"contacts": {
"title": "Contacts",
"subtitle": "Manage notification recipients.",
"count": "{{count}} contact(s)",
"addContact": "Add Contact",
"editContact": "Edit Contact",
"deleteContact": "Delete Contact",
"empty": "No contacts configured.",
"form": {
"name": "Name",
"email": "Email",
"phone": "Phone",
"role": "Role",
"channels": "Preferred Channels"
},
"table": {
"name": "Name",
"email": "Email",
"phone": "Phone",
"role": "Role",
"channels": "Channels",
"actions": "Actions"
}
},
"escalationPolicies": {
"title": "Escalation Policies",
"subtitle": "Define how alerts escalate when not acknowledged.",
"count": "{{count}} policy(ies)",
"addPolicy": "Add Policy",
"editPolicy": "Edit Policy",
"deletePolicy": "Delete Policy",
"default": "Default",
"empty": "No escalation policies configured.",
"editSteps": "Edit Steps",
"collapseSteps": "Collapse",
"form": {
"name": "Name",
"description": "Description",
"severity": "Severity Trigger",
"steps": "Escalation Steps",
"setAsDefault": "Set as default policy"
},
"step": {
"title": "Step {{number}}",
"delay": "Delay",
"delayMinutes": "minutes",
"channels": "Channels",
"contacts": "Contacts",
"addStep": "Add Step",
"removeStep": "Remove Step"
},
"severityOptions": {
"info": "Info",
"warning": "Warning",
"critical": "Critical",
"fatal": "Fatal"
},
"table": {
"name": "Name",
"severity": "Severity",
"steps": "Steps",
"actions": "Actions"
}
}
}

View File

@ -0,0 +1,27 @@
{
"title": "Dashboard",
"subtitle": "System overview and recent activity",
"stats": {
"totalServers": "Total Servers",
"activeAlerts": "Active Alerts",
"runningTasks": "Running Tasks",
"standingOrders": "Standing Orders"
},
"recentAlerts": {
"title": "Recent Alerts",
"severity": "Severity",
"message": "Message",
"server": "Server",
"time": "Time",
"empty": "No recent alerts.",
"viewAll": "View all alerts"
},
"recentTasks": {
"title": "Recent Tasks",
"name": "Name",
"status": "Status",
"created": "Created",
"empty": "No recent tasks.",
"viewAll": "View all tasks"
}
}

View File

@ -0,0 +1,173 @@
{
"alertRules": {
"title": "Alert Rules",
"subtitle": "Configure alert rules to monitor server metrics and receive notifications.",
"addRule": "Add Rule",
"editRule": "Edit Rule",
"deleteRule": "Delete Alert Rule",
"loading": "Loading alert rules...",
"loadError": "Failed to load alert rules:",
"empty": "No alert rules found.",
"table": {
"name": "Name",
"metric": "Metric",
"condition": "Condition",
"threshold": "Threshold",
"severity": "Severity",
"enabled": "Enabled",
"actions": "Actions"
},
"form": {
"name": "Name",
"description": "Description",
"metric": "Metric",
"condition": "Condition",
"threshold": "Threshold",
"severity": "Severity",
"notifyChannels": "Notify Channels",
"notifyChannelsPlaceholder": "email, slack, webhook (comma-separated)",
"cooldownMinutes": "Cooldown (minutes)",
"enabled": "Enabled"
},
"validation": {
"nameRequired": "Name is required",
"metricRequired": "Metric is required",
"thresholdRequired": "Valid threshold is required",
"cooldownRequired": "Valid cooldown is required"
},
"metrics": {
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
"diskUsage": "Disk Usage",
"networkLatency": "Network Latency",
"httpErrorRate": "HTTP Error Rate",
"custom": "Custom"
},
"conditions": {
"greaterThan": "> (greater than)",
"lessThan": "< (less than)",
"equal": "== (equal)",
"greaterOrEqual": ">= (greater or equal)",
"lessOrEqual": "<= (less or equal)"
},
"severities": {
"info": "Info",
"warning": "Warning",
"critical": "Critical",
"fatal": "Fatal"
},
"deleteConfirm": "Are you sure you want to delete this rule? This will stop all future alerts and cannot be undone.",
"detail": {
"backToAlertRules": "Back to Alert Rules",
"ruleConfiguration": "Rule Configuration",
"recentAlertEvents": "Recent Alert Events",
"ruleSummary": "Rule Summary",
"notifyChannels": "Notify Channels",
"quickActions": "Quick Actions",
"metadata": "Metadata",
"ruleExpression": "Rule Expression",
"loading": "Loading alert rule...",
"loadError": "Failed to load alert rule:",
"noDescription": "No description",
"noneConfigured": "None configured",
"noNotificationChannels": "No notification channels configured.",
"noAlertEvents": "No alert events triggered by this rule yet.",
"disableRule": "Disable Rule",
"enableRule": "Enable Rule",
"editRule": "Edit Rule",
"deleteRule": "Delete Rule",
"updating": "Updating...",
"failedToUpdate": "Failed to update rule:",
"ruleId": "Rule ID",
"cooldown": "Cooldown",
"totalEvents": "Total Events",
"eventTable": {
"severity": "Severity",
"metric": "Metric",
"value": "Value",
"threshold": "Threshold",
"message": "Message",
"status": "Status",
"triggered": "Triggered"
},
"eventStatus": {
"resolved": "Resolved",
"active": "Active"
}
}
},
"healthChecks": {
"title": "Health Checks",
"subtitle": "Monitor server health and availability",
"loading": "Loading health checks...",
"loadError": "Failed to load health checks:",
"empty": "No health checks found.",
"noMatchingStatus": "No servers with status \"{{status}}\" found.",
"autoRefresh": "Auto-refresh:",
"live": "Live",
"refreshOptions": {
"off": "Off",
"10s": "10s",
"30s": "30s",
"60s": "60s"
},
"stats": {
"totalServers": "Total Servers",
"healthy": "Healthy",
"degraded": "Degraded",
"down": "Down"
},
"filters": {
"all": "All",
"healthy": "Healthy",
"degraded": "Degraded",
"down": "Down"
},
"card": {
"latency": "Latency",
"uptime24h": "Uptime (24h)",
"lastCheck": "Last Check"
},
"checkTypes": {
"ping": "ping",
"tcp": "tcp",
"http": "http"
}
},
"metrics": {
"title": "Metrics Dashboard",
"subtitle": "Monitor server performance and resource utilization",
"loading": "Loading server metrics...",
"loadError": "Failed to load server metrics:",
"empty": "No server metrics available.",
"noMatchingFilters": "No servers match the current filters.",
"autoRefresh": "Auto-refresh (30s)",
"live": "Live",
"searchPlaceholder": "Search by hostname...",
"showing": "Showing {{count}} of {{total}} servers",
"overview": {
"totalServers": "Total Servers",
"online": "Online",
"avgCpu": "Avg CPU",
"avgMemory": "Avg Memory",
"alertsToday": "Alerts Today"
},
"filters": {
"all": "All",
"dev": "Dev",
"staging": "Staging",
"prod": "Prod",
"online": "Online",
"offline": "Offline"
},
"table": {
"hostname": "Hostname",
"env": "Env",
"status": "Status",
"cpuPercent": "CPU %",
"memoryPercent": "Memory %",
"diskPercent": "Disk %",
"lastChecked": "Last Checked"
}
}
}

View File

@ -0,0 +1,75 @@
{
"title": "Runbooks",
"subtitle": "Manage operations runbooks and automation scripts.",
"newRunbook": "New Runbook",
"loading": "Loading runbooks...",
"loadError": "Failed to load runbooks:",
"empty": "No runbooks yet.",
"triggerTypes": {
"manual": "Manual",
"alert": "Alert",
"scheduled": "Scheduled"
},
"riskLevels": {
"0": "L0 - Info",
"1": "L1 - Low",
"2": "L2 - Medium",
"3": "L3 - High"
},
"form": {
"name": "Name",
"description": "Description",
"triggerType": "Trigger Type",
"promptTemplate": "Prompt Template",
"allowedTools": "Allowed Tools",
"maxRiskLevel": "Max Risk Level",
"autoApprove": "Auto-Approve",
"enabled": "Enabled"
},
"table": {
"name": "Name",
"description": "Description",
"trigger": "Trigger",
"maxRisk": "Max Risk",
"autoApprove": "Auto-Approve",
"actions": "Actions"
},
"deleteDialog": {
"title": "Delete Runbook",
"message": "Are you sure you want to delete this runbook? This action cannot be undone. All execution history will also be removed."
},
"detail": {
"backToRunbooks": "Back to Runbooks",
"back": "Back",
"overview": "Overview",
"promptTemplate": "Prompt Template",
"allowedTools": "Allowed Tools",
"executionHistory": "Execution History",
"configuration": "Configuration",
"quickActions": "Quick Actions",
"loading": "Loading runbook...",
"loadError": "Failed to load runbook:",
"notFound": "Runbook not found.",
"noDescription": "No description provided.",
"noPrompt": "No prompt template defined.",
"noTools": "No tools configured.",
"noExecutions": "No executions yet.",
"enableToExecute": "Enable the runbook to execute it.",
"triggerType": "Trigger Type",
"maxRiskLevel": "Max Risk Level",
"autoApprove": "Auto-Approve",
"autoApproveDescription": "Auto-approve actions within the configured risk level.",
"statusLabel": "Status",
"executeNow": "Execute Now",
"executing": "Executing...",
"duplicate": "Duplicate",
"duplicating": "Duplicating...",
"deleteRunbook": "Delete Runbook",
"executionTable": {
"date": "Date",
"status": "Status",
"duration": "Duration",
"triggeredBy": "Triggered By"
}
}
}

View File

@ -0,0 +1,151 @@
{
"title": "Security & Risk Rules",
"subtitle": "Configure command risk classification rules and security policies.",
"riskRules": {
"title": "Command Risk Rules",
"addRule": "Add Rule",
"editRule": "Edit Rule",
"deleteRule": "Delete Rule",
"loading": "Loading risk rules...",
"loadError": "Failed to load risk rules:",
"empty": "No risk rules configured.",
"table": {
"pattern": "Pattern",
"riskLevel": "Risk Level",
"action": "Action",
"description": "Description",
"actions": "Actions"
},
"form": {
"pattern": "Pattern",
"patternPlaceholder": "rm -rf *, sudo *, etc.",
"riskLevel": "Risk Level",
"action": "Action",
"description": "Description"
},
"riskLevels": {
"0": "0 - None",
"1": "1 - Low",
"2": "2 - Medium",
"3": "3 - High"
},
"actions": {
"allow": "Allow",
"block": "Block",
"requireApproval": "Require Approval"
}
},
"permissionMatrix": {
"title": "Permission Matrix",
"roles": {
"admin": "Admin",
"operator": "Operator",
"viewer": "Viewer",
"readonly": "Read-only"
},
"permissions": {
"manageServers": "Manage Servers",
"executeCommands": "Execute Commands",
"viewAudit": "View Audit",
"manageUsers": "Manage Users",
"approveCommands": "Approve Commands"
},
"savePermissions": "Save Permissions"
},
"credentials": {
"title": "Credentials",
"subtitle": "Manage SSH credentials for server connections",
"addCredential": "Add Credential",
"editCredential": "Edit Credential",
"deleteCredential": "Delete Credential",
"testCredential": "Test",
"loading": "Loading credentials...",
"loadError": "Failed to load credentials:",
"empty": "No credentials found.",
"securityNotice": "Credentials are stored encrypted and are only decrypted during use.",
"authTypes": {
"password": "Password",
"sshKey": "SSH Key",
"sshKeyPassphrase": "SSH Key + Passphrase"
},
"table": {
"name": "Name",
"type": "Type",
"username": "Username",
"associatedServers": "Associated Servers",
"created": "Created",
"actions": "Actions"
},
"form": {
"name": "Name",
"authType": "Authentication Type",
"username": "Username",
"password": "Password",
"privateKey": "Private Key",
"passphrase": "Passphrase",
"servers": "Associated Servers"
},
"encryptionWarning": "Encrypted data cannot be retrieved after saving."
},
"roles": {
"title": "Roles",
"subtitle": "Manage roles and their associated permissions",
"addRole": "Add Role",
"editRole": "Edit Role",
"deleteRole": "Delete Role",
"loading": "Loading roles...",
"loadError": "Failed to load roles:",
"empty": "No roles found.",
"builtIn": "Built-in",
"custom": "Custom",
"showPerms": "Permissions",
"hidePerms": "Hide Perms",
"assignedPermissions": "Assigned Permissions",
"savingPermissions": "Saving permissions...",
"table": {
"name": "Name",
"description": "Description",
"type": "Type",
"permissions": "Permissions",
"users": "Users",
"created": "Created",
"actions": "Actions"
},
"form": {
"name": "Name",
"description": "Description"
}
},
"permissions": {
"title": "Permissions",
"subtitle": "View and manage the permission matrix across roles",
"loading": "Loading permissions matrix...",
"loadError": "Failed to load permissions:",
"empty": "No permissions found.",
"resource": "Resource",
"permission": "Permission",
"action": "Action",
"details": "Permission Details",
"actionTypes": {
"create": "create",
"read": "read",
"update": "update",
"delete": "delete",
"execute": "execute"
},
"summary": {
"title": "Summary",
"totalPermissions": "Total Permissions",
"totalRoles": "Total Roles",
"resources": "Resources",
"granted": "Granted"
},
"detailDialog": {
"title": "Permission Details",
"key": "Key",
"resource": "Resource",
"action": "Action",
"description": "Description"
}
}
}

View File

@ -0,0 +1,97 @@
{
"title": "Servers",
"subtitle": "Manage server inventory, clusters, and SSH configurations.",
"addServer": "Add Server",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"loading": "Loading servers...",
"loadError": "Failed to load servers:",
"empty": "No servers found.",
"filters": {
"all": "All",
"dev": "Dev",
"staging": "Staging",
"prod": "Prod"
},
"table": {
"hostname": "Hostname",
"host": "Host",
"environment": "Environment",
"role": "Role",
"status": "Status",
"actions": "Actions"
},
"form": {
"hostname": "Hostname",
"hostIp": "Host (IP)",
"sshPort": "SSH Port",
"environment": "Environment",
"role": "Role",
"tags": "Tags",
"description": "Description",
"tagsPlaceholder": "tag1, tag2 (comma-separated)",
"descriptionPlaceholder": "Optional description..."
},
"validation": {
"hostnameRequired": "Hostname is required",
"hostRequired": "Host is required"
},
"dialog": {
"deleteConfirm": "Are you sure you want to delete",
"deleteWarning": "This action cannot be undone. The server and all its associated data will be permanently removed."
},
"detail": {
"backToServers": "Back to Servers",
"serverInformation": "Server Information",
"recentHealthChecks": "Recent Health Checks",
"recentCommands": "Recent Commands",
"quickActions": "Quick Actions",
"connectionInfo": "Connection Info",
"tags": "Tags",
"noTags": "No tags assigned.",
"loading": "Loading server details...",
"loadError": "Failed to load server:",
"noHealthChecks": "No health checks recorded yet.",
"noCommands": "No commands executed on this server yet.",
"saveChanges": "Save Changes",
"openTerminal": "Open Terminal",
"runHealthCheck": "Run Health Check",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"healthCheckTable": {
"status": "Status",
"latency": "Latency",
"message": "Message",
"time": "Time"
},
"commandsTable": {
"command": "Command",
"exitCode": "Exit Code",
"risk": "Risk",
"time": "Time"
}
},
"clusters": {
"title": "Clusters",
"subtitle": "Organize servers into logical groups",
"addCluster": "Add Cluster",
"editCluster": "Edit Cluster",
"deleteCluster": "Delete Cluster",
"loading": "Loading clusters...",
"loadError": "Failed to load clusters:",
"empty": "No clusters found.",
"form": {
"name": "Name",
"description": "Description",
"environment": "Environment",
"tags": "Tags",
"servers": "Servers"
},
"health": {
"online": "online",
"offline": "offline",
"maintenance": "maintenance",
"noServers": "No servers"
}
}
}

View File

@ -0,0 +1,46 @@
{
"title": "Sessions",
"subtitle": "View and manage agent sessions",
"loading": "Loading sessions...",
"loadError": "Failed to load sessions:",
"empty": "No sessions found.",
"statuses": {
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled"
},
"detail": {
"title": "Session",
"backToSessions": "Back to Sessions",
"sessionInformation": "Session Information",
"eventStream": "Event Stream",
"statistics": "Statistics",
"tasks": "Tasks",
"serverTargets": "Server Targets",
"timestamps": "Timestamps",
"loading": "Loading session details...",
"loadError": "Failed to load session:",
"autoScroll": "Auto-scroll",
"liveStreaming": "Live -- streaming events...",
"noEvents": "No events recorded yet.",
"noTasks": "No tasks in this session.",
"info": {
"sessionId": "Session ID",
"status": "Status",
"engine": "Engine",
"startedAt": "Started At",
"endedAt": "Ended At",
"duration": "Duration",
"tokenCount": "Token Count",
"totalCost": "Total Cost",
"task": "Task"
},
"engines": {
"claudeCli": "Claude Code CLI",
"claudeApi": "Claude API"
},
"inProgressDuration": "In progress...",
"inProgressStatus": "In progress"
}
}

View File

@ -0,0 +1,79 @@
{
"title": "Settings",
"subtitle": "Application preferences and configurations.",
"sections": {
"general": "General",
"notifications": "Notifications",
"apikeys": "API Keys",
"theme": "Theme",
"account": "Account"
},
"general": {
"title": "General Settings",
"platformName": "Platform Name",
"platformNamePlaceholder": "IT0 Platform",
"defaultTimezone": "Default Timezone",
"defaultLanguage": "Default Language",
"uiLanguage": "Interface Language",
"uiLanguageHint": "Change the display language for this browser.",
"autoApproveThreshold": "Auto-Approve Threshold (Risk Level 0-3)",
"autoApproveHint": "Operations at or below this risk level will be auto-approved.",
"saved": "Settings saved successfully."
},
"notifications": {
"title": "Notification Settings",
"email": "Email Notifications",
"sms": "SMS Notifications",
"push": "Push Notifications",
"escalationPolicy": "Default Escalation Policy",
"saved": "Notification settings saved."
},
"apikeys": {
"title": "API Keys",
"namePlaceholder": "Key name (e.g. CI/CD Pipeline)",
"generate": "Generate New Key",
"generatedWarning": "New API key generated. Copy it now -- it will not be shown again.",
"copyToClipboard": "Copy to Clipboard",
"dismiss": "Dismiss",
"headerName": "Name",
"headerKey": "Key",
"headerCreated": "Created",
"headerLastUsed": "Last Used",
"headerActions": "Actions",
"revoke": "Revoke",
"noKeys": "No API keys. Generate one above."
},
"theme": {
"title": "Theme",
"appearance": "Appearance",
"light": "Light",
"dark": "Dark",
"primaryColor": "Primary Color",
"customColor": "Custom color",
"selected": "Selected: {{color}}",
"saveTheme": "Save Theme",
"saved": "Theme settings saved."
},
"account": {
"profileTitle": "Account Profile",
"displayName": "Display Name",
"email": "Email",
"emailHint": "Email cannot be changed here.",
"saveProfile": "Save Profile",
"profileSaved": "Profile updated.",
"changePassword": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"passwordChanged": "Password changed successfully.",
"changing": "Changing..."
},
"languages": {
"en": "English",
"zh": "中文",
"ko": "한국어",
"ja": "日本語",
"de": "Deutsch",
"fr": "Français"
}
}

View File

@ -0,0 +1,33 @@
{
"appName": "IT0 Admin",
"appSubtitle": "Operations Console",
"dashboard": "Dashboard",
"agentConfig": "Agent Config",
"enginePrompt": "Engine & Prompt",
"sdkConfig": "SDK Config",
"skills": "Skills",
"hooks": "Hooks",
"runbooks": "Runbooks",
"standingOrders": "Standing Orders",
"servers": "Servers",
"allServers": "All Servers",
"clusters": "Clusters",
"monitoring": "Monitoring",
"alertRules": "Alert Rules",
"healthChecks": "Health Checks",
"metrics": "Metrics",
"terminal": "Terminal",
"security": "Security",
"riskRules": "Risk Rules",
"credentials": "Credentials",
"roles": "Roles",
"permissions": "Permissions",
"audit": "Audit",
"logs": "Logs",
"sessionReplay": "Session Replay",
"communication": "Communication",
"tenants": "Tenants",
"users": "Users",
"settings": "Settings",
"collapse": "Collapse"
}

View File

@ -0,0 +1,111 @@
{
"title": "Standing Orders",
"subtitle": "Manage autonomous operation tasks and execution schedules.",
"newOrder": "New Standing Order",
"loading": "Loading standing orders...",
"loadError": "Failed to load standing orders:",
"empty": "No standing orders yet.",
"triggerTypes": {
"cron": "Cron Schedule",
"event": "Event",
"threshold": "Threshold"
},
"statuses": {
"active": "Active",
"paused": "Paused"
},
"form": {
"name": "Name",
"description": "Description",
"triggerType": "Trigger Type",
"triggerConfiguration": "Trigger Configuration",
"cronExpression": "Cron Expression",
"eventType": "Event Type",
"metric": "Metric",
"condition": "Condition",
"thresholdValue": "Threshold Value",
"targetServers": "Target Servers",
"targetServersPlaceholder": "server-1, server-2 (comma-separated)",
"agentInstruction": "Agent Instruction",
"maxBudget": "Max Budget (USD)",
"escalateOnFailure": "Escalate on failure"
},
"table": {
"name": "Name",
"trigger": "Trigger",
"status": "Status",
"lastExecution": "Last Execution",
"nextRun": "Next Run",
"actions": "Actions"
},
"executionHistory": {
"title": "Execution History",
"loading": "Loading executions...",
"loadError": "Failed to load executions:",
"empty": "No executions recorded yet.",
"table": {
"date": "Date",
"status": "Status",
"duration": "Duration",
"commands": "Commands",
"summary": "Summary",
"cost": "Cost"
},
"executionStatuses": {
"running": "Running",
"completed": "Completed",
"failed": "Failed"
},
"executionSummary": "Execution Summary",
"noSummary": "No summary available.",
"inProgress": "In progress...",
"started": "Started:",
"ended": "Ended:"
},
"deleteDialog": {
"title": "Delete Standing Order",
"message": "Are you sure you want to delete this standing order? This will stop all future executions and cannot be undone."
},
"detail": {
"backToStandingOrders": "Back to Standing Orders",
"overview": "Overview",
"triggerConfiguration": "Trigger Configuration",
"agentInstruction": "Agent Instruction",
"targetServers": "Target Servers",
"noTargetServers": "No target servers configured.",
"maxBudget": "Max Budget",
"statusAndControls": "Status & Controls",
"quickActions": "Quick Actions",
"statistics": "Statistics",
"metadata": "Metadata",
"loading": "Loading standing order...",
"loadError": "Failed to load standing order:",
"notFound": "Standing order not found.",
"pauseOrder": "Pause Order",
"activateOrder": "Activate Order",
"executeNow": "Execute Now",
"triggering": "Triggering...",
"executionTriggered": "Execution triggered successfully.",
"edit": "Edit",
"editStandingOrder": "Edit Standing Order",
"viewLogs": "View Logs",
"delete": "Delete",
"escalateOnFailure": "Escalate on Failure",
"lastExecution": "Last Execution",
"nextRun": "Next Run",
"expression": "Expression:",
"eventType": "Event Type:",
"stats": {
"totalExecutions": "Total Executions",
"successRate": "Success Rate",
"avgDuration": "Avg Duration",
"lastFailure": "Last Failure"
},
"pagination": {
"showing": "Showing {{from}} - {{to}} of {{total}}",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{current}} of {{total}}"
}
}
}

View File

@ -0,0 +1,107 @@
{
"title": "Tenant Management",
"subtitle": "Manage tenants, plans, and resource quotas.",
"newTenant": "+ New Tenant",
"createTenant": "Create New Tenant",
"loading": "Loading tenants...",
"loadError": "Failed to load tenants:",
"empty": "No tenants found. Create your first tenant to get started.",
"form": {
"name": "Name",
"slug": "Slug",
"plan": "Plan",
"adminEmail": "Admin Email",
"status": "Status"
},
"plans": {
"free": "Free",
"pro": "Pro",
"enterprise": "Enterprise"
},
"statuses": {
"active": "Active",
"suspended": "Suspended"
},
"table": {
"name": "Name",
"slug": "Slug",
"plan": "Plan",
"status": "Status",
"users": "Users",
"created": "Created",
"actions": "Actions"
},
"actions": {
"suspend": "Suspend",
"activate": "Activate",
"quotas": "Quotas",
"hideQuotas": "Hide Quotas"
},
"quotas": {
"title": "Resource Quotas",
"maxServers": "Max Servers",
"maxUsers": "Max Users",
"maxStandingOrders": "Max Standing Orders",
"maxAgentTokens": "Max Agent Tokens / Month"
},
"createDialog": {
"create": "Create Tenant",
"creating": "Creating..."
},
"detail": {
"backToTenants": "Back to Tenants",
"tenantInformation": "Tenant Information",
"members": "Members",
"invitations": "Invitations",
"quickActions": "Quick Actions",
"metadata": "Metadata",
"loading": "Loading tenant details...",
"loadError": "Failed to load tenant:",
"failedToUpdate": "Failed to update tenant:",
"schemaName": "Schema Name",
"memberCount": "Members",
"noMembers": "No members found.",
"noInvitations": "No invitations sent yet.",
"inviteUser": "+ Invite User",
"sendInvite": "Send Invite",
"sending": "Sending...",
"revoke": "Revoke",
"suspendTenant": "Suspend Tenant",
"activateTenant": "Activate Tenant",
"viewAuditLog": "View Audit Log",
"editTenant": "Edit Tenant",
"deleteTenant": "Delete Tenant",
"tenantId": "Tenant ID",
"slug": "Slug",
"schema": "Schema",
"membersTable": {
"name": "Name",
"email": "Email",
"role": "Role",
"joined": "Joined"
},
"invitesTable": {
"email": "Email",
"role": "Role",
"status": "Status",
"actions": "Actions"
},
"inviteForm": {
"email": "Email",
"emailPlaceholder": "user@example.com",
"role": "Role",
"roles": {
"viewer": "Viewer",
"operator": "Operator",
"admin": "Admin"
}
},
"deleteDialog": {
"title": "Delete Tenant",
"message": "Are you sure you want to delete this tenant? This will permanently remove the tenant, all its data, and all member associations. This action cannot be undone."
},
"validation": {
"nameRequired": "Name is required"
}
}
}

View File

@ -0,0 +1,44 @@
{
"title": "Terminal",
"subtitle": "Remote shell access",
"connection": {
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected"
},
"server": {
"label": "Server:",
"selectServer": "Select a server",
"loadingServers": "Loading servers..."
},
"actions": {
"connect": "Connect",
"disconnect": "Disconnect",
"clear": "Clear"
},
"noServerSelected": "No server selected",
"selectServerPrompt": "Select a server and click Connect to start a terminal session.",
"messages": {
"noAuthToken": "No authentication token found. Please log in first.",
"connecting": "Connecting to {{hostname}} ({{host}})...",
"connectedTo": "Connected to {{hostname}}. Type commands below.",
"connected": "Connected to {{server}}",
"disconnected": "Disconnected from server.",
"disconnectedFrom": "Disconnected from {{hostname}} (code {{code}}).{{reason}}",
"connectionFailed": "Connection failed:",
"unknownError": "Unknown error",
"wsError": "WebSocket error occurred.",
"notConnected": "Not connected. Please connect to a server first.",
"reconnecting": "Reconnecting...",
"sessionTimeout": "Session timed out."
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"sendCommand": "Send command",
"commandHistory": "Command history",
"clearTerminal": "Clear terminal"
},
"readyMessage": "Terminal ready. Type a command and press Enter.",
"placeholder": "Type a command...",
"placeholderDisconnected": "Connect to a server to start typing"
}

View File

@ -0,0 +1,10 @@
{
"search": "Search...",
"shortcutKey": "⌘K",
"tenant": "Tenant:",
"notSelected": "Not selected",
"settings": "Settings",
"users": "Users",
"signOut": "Sign out",
"defaultUser": "User"
}

View File

@ -0,0 +1,89 @@
{
"title": "Users",
"subtitle": "Manage user accounts and access",
"addUser": "Add User",
"loading": "Loading users...",
"loadError": "Failed to load users:",
"empty": "No users found. Add one to get started.",
"noMatchingFilters": "No users match the current filters.",
"showing": "Showing {{count}} of {{total}} users",
"searchPlaceholder": "Search by name or email...",
"allRoles": "All Roles",
"roles": {
"admin": "Admin",
"operator": "Operator",
"viewer": "Viewer"
},
"statuses": {
"active": "Active",
"disabled": "Disabled"
},
"table": {
"name": "Name",
"email": "Email",
"role": "Role",
"tenant": "Tenant",
"status": "Status",
"lastLogin": "Last Login",
"created": "Created",
"actions": "Actions"
},
"form": {
"displayName": "Display Name",
"email": "Email",
"password": "Password",
"role": "Role",
"tenantId": "Tenant ID"
},
"createDialog": {
"title": "Add User",
"create": "Create User",
"creating": "Creating..."
},
"editDialog": {
"title": "Edit User:",
"save": "Save Changes",
"saving": "Saving..."
},
"deleteDialog": {
"title": "Delete User",
"message": "Are you sure you want to delete this user? This action cannot be undone.",
"warning": "The user will lose all access and their sessions will be terminated immediately."
},
"validation": {
"displayNameRequired": "Display name is required",
"emailRequired": "Email is required",
"passwordRequired": "Password is required"
},
"detail": {
"backToUsers": "Back to Users",
"userInformation": "User Information",
"activityLog": "Activity Log",
"quickActions": "Quick Actions",
"accountSummary": "Account Summary",
"loading": "Loading user details...",
"loadError": "Failed to load user:",
"failedToUpdate": "Failed to update user:",
"noActivity": "No activity recorded yet.",
"editUser": "Edit User",
"resetPassword": "Reset Password",
"deleteUser": "Delete User",
"memberSince": "Member Since",
"lastLogin": "Last Login",
"activityTable": {
"action": "Action",
"resource": "Resource",
"details": "Details",
"ipAddress": "IP Address",
"time": "Time"
},
"resetPasswordDialog": {
"title": "Reset Password",
"message": "This will send a password reset link to this user's email address. The user will need to set a new password.",
"sendResetLink": "Send Reset Link",
"sending": "Sending...",
"success": "A password reset link has been sent to the user's email address.",
"done": "Done"
}
}
}

View File

@ -0,0 +1,175 @@
{
"title": "代理配置",
"subtitle": "管理 AI 引擎设置、系统提示词和允许的工具。",
"loading": "正在加载代理配置...",
"loadError": "加载配置失败,使用默认设置。",
"saveSuccess": "配置已成功保存。",
"saveConfig": "保存配置",
"resetDefaults": "恢复默认设置",
"engine": {
"title": "引擎",
"claudeCli": "Claude CLI",
"claudeCliDesc": "本地 Claude Code CLI 进程",
"claudeApi": "Claude API",
"claudeApiDesc": "直接 Anthropic API 集成"
},
"systemPrompt": {
"title": "系统提示词",
"placeholder": "输入 AI 代理的系统提示词..."
},
"maxTurns": {
"title": "最大轮次",
"description": "每个会话的最大对话轮次。"
},
"maxBudget": {
"title": "最大预算(美元)",
"description": "每个会话的最大 Token 费用。"
},
"allowedTools": {
"title": "允许的工具",
"selectAll": "全选",
"deselectAll": "全不选",
"tools": {
"Bash": "执行 Shell 命令",
"Read": "读取文件内容",
"Write": "写入/创建文件",
"Edit": "编辑现有文件",
"Glob": "按模式搜索文件",
"Grep": "搜索文件内容",
"WebFetch": "获取网页内容",
"WebSearch": "搜索网络",
"NotebookEdit": "编辑 Jupyter 笔记本",
"Task": "启动子代理任务"
}
},
"sdk": {
"title": "Agent SDK 配置",
"subtitle": "配置每个租户的 Claude Agent SDK 计费方式、审批流程和工具权限。",
"loading": "正在加载 SDK 配置...",
"loadError": "加载配置失败,使用默认设置。",
"billingMode": {
"title": "计费模式",
"subtitle": "选择该租户的 Claude Agent SDK 计费方式。",
"subscription": "订阅制",
"subscriptionDesc": "使用运维人员的 Claude 登录(无需 API 密钥)",
"apiKey": "API 密钥",
"apiKeyDesc": "租户自有的 Anthropic API 密钥(按 Token 计费)"
},
"apiKey": {
"label": "Anthropic API 密钥",
"configured": "密钥已配置",
"replacePlaceholder": "输入新密钥以替换现有密钥",
"placeholder": "sk-ant-...",
"remove": "移除",
"encryptionNote": "API 密钥在存储前已加密AES-256-GCM不会以明文存储。"
},
"approvalTimeout": {
"title": "L2 审批超时",
"subtitle": "对于高风险命令L2等待人工审批的时间超时后自动批准。设置为 0 则禁用自动批准(无限等待)。",
"unit": "秒",
"autoApproveDisabled": "自动批准已禁用——命令将无限等待人工审批。",
"autoApproveAfter": "无响应 {{seconds}} 秒后命令将自动批准。"
},
"toolPermissions": {
"title": "工具权限",
"subtitle": "按租户覆盖工具访问权限。白名单为空时使用 RBAC 默认设置。黑名单中的工具无论角色如何都会被拒绝。",
"tool": "工具",
"description": "描述",
"whitelist": "白名单",
"blacklist": "黑名单",
"whitelistCount": "白名单:{{count}}",
"whitelistNone": "白名单:无(使用 RBAC 默认设置)",
"blacklistCount": "黑名单:{{count}}",
"blacklistNone": "黑名单:无"
},
"rbac": {
"title": "RBAC 工具访问(参考)",
"subtitle": "每个角色的默认工具访问权限(白名单为空时生效)。",
"admin": "管理员",
"adminTools": "全部工具Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task",
"operator": "运维人员",
"operatorTools": "Bash, Read, Write, Edit, Glob, Grep",
"viewer": "只读用户",
"viewerTools": "Read, Glob, Grep只读"
}
},
"skills": {
"title": "技能",
"subtitle": "管理 AI 代理的 Claude Code 技能",
"addSkill": "添加技能",
"editSkill": "编辑技能",
"deleteSkill": "删除技能",
"loading": "正在加载技能...",
"loadError": "加载技能失败:",
"empty": "暂无配置的技能。",
"categories": {
"inspection": "巡检",
"deployment": "部署",
"maintenance": "维护",
"security": "安全",
"monitoring": "监控",
"custom": "自定义"
},
"form": {
"name": "名称",
"description": "描述",
"category": "分类",
"script": "脚本",
"tags": "标签",
"enabled": "启用"
},
"detail": {
"backToSkills": "返回技能列表",
"overview": "概览",
"promptTemplate": "提示词模板",
"allowedTools": "允许的工具",
"configuration": "配置",
"quickActions": "快捷操作",
"metadata": "元数据",
"duplicateSkill": "复制技能",
"deleteSkill": "删除技能",
"builtIn": "内置",
"custom": "自定义",
"enabled": "已启用",
"disabled": "已禁用",
"noPrompt": "未定义提示词模板。",
"noTools": "未配置工具。",
"notFound": "技能未找到。",
"loading": "正在加载技能..."
}
},
"hooks": {
"title": "钩子脚本",
"subtitle": "代理工具执行的生命周期脚本",
"addHook": "添加钩子",
"editHook": "编辑钩子",
"deleteHook": "删除钩子",
"loading": "正在加载钩子...",
"loadError": "加载钩子失败:",
"empty": "暂无配置的钩子。",
"infoBanner": "钩子脚本在代理工具调用前后运行。用于日志记录、验证或自定义副作用。",
"table": {
"name": "名称",
"event": "事件",
"toolPattern": "工具匹配",
"script": "脚本",
"enabled": "启用",
"actions": "操作"
},
"form": {
"name": "名称",
"eventType": "事件类型",
"toolPattern": "工具匹配",
"script": "脚本",
"timeout": "超时",
"enabled": "启用",
"description": "描述"
},
"events": {
"beforeTool": "工具执行前",
"afterTool": "工具执行后",
"onError": "出错时",
"onApproval": "审批时"
}
}
}

View File

@ -0,0 +1,98 @@
{
"logs": {
"title": "审计日志",
"subtitle": "查看所有操作和配置变更的不可篡改审计记录。",
"exportCsv": "导出 CSV",
"loading": "正在加载审计日志...",
"loadError": "加载审计日志失败:",
"empty": "当前筛选条件下暂无审计日志。",
"filters": {
"dateFrom": "起始日期",
"dateTo": "截止日期",
"actionType": "操作类型",
"actorType": "执行者类型",
"resourceType": "资源类型"
},
"table": {
"timestamp": "时间戳",
"action": "操作",
"actorType": "执行者类型",
"actorId": "执行者 ID",
"resourceType": "资源类型",
"resourceId": "资源 ID",
"description": "描述"
},
"pagination": {
"rowsPerPage": "每页行数",
"previous": "上一页",
"next": "下一页",
"pageOf": "第 {{current}} 页,共 {{total}} 页"
},
"fullDetail": "完整详情"
},
"replay": {
"title": "会话回放",
"subtitle": "查看代理会话的执行历史",
"loading": "正在加载会话...",
"loadError": "加载会话失败:",
"empty": "当前筛选条件下暂无会话。",
"showing": "显示 {{count}} / {{total}} 个会话",
"filters": {
"dateFrom": "起始日期",
"dateTo": "截止日期",
"status": "状态",
"search": "搜索",
"searchPlaceholder": "会话 ID 或任务描述..."
},
"statuses": {
"all": "全部",
"completed": "已完成",
"failed": "失败",
"cancelled": "已取消",
"running": "运行中"
},
"table": {
"sessionId": "会话 ID",
"taskDescription": "任务描述",
"status": "状态",
"duration": "时长",
"commands": "命令数",
"startedAt": "开始时间"
},
"panel": {
"title": "会话回放",
"close": "关闭",
"id": "ID",
"duration": "时长:",
"commands": "命令数:",
"servers": "服务器:"
},
"playback": {
"play": "播放",
"pause": "暂停",
"reset": "重置",
"showAll": "显示全部",
"speed": "速度:",
"events": "个事件",
"eventsProgress": "{{current}} / {{total}} 个事件"
},
"events": {
"loading": "正在加载会话事件...",
"loadError": "加载事件失败:",
"empty": "该会话暂无记录的事件。",
"pressPlay": "点击播放开始会话回放。",
"collapse": "收起",
"expandFullOutput": "展开完整输出",
"types": {
"commandExecuted": "命令已执行",
"outputReceived": "收到输出",
"approvalRequested": "请求审批",
"approvalGranted": "审批通过",
"approvalDenied": "审批拒绝",
"error": "错误",
"sessionStarted": "会话已开始",
"sessionCompleted": "会话已完成"
}
}
}
}

View File

@ -0,0 +1,35 @@
{
"appTitle": "IT0",
"adminConsole": "管理控制台",
"signIn": "登录",
"signingIn": "登录中...",
"email": "邮箱",
"password": "密码",
"emailPlaceholder": "admin@example.com",
"noAccount": "还没有账号?",
"createOne": "立即注册",
"haveAccount": "已有账号?",
"signInLink": "去登录",
"createAccount": "创建账号",
"createAccountSubtitle": "注册新组织,开始使用。",
"fullName": "姓名",
"fullNamePlaceholder": "张三",
"organizationName": "组织名称",
"organizationNamePlaceholder": "示例公司",
"organizationHint": "将为您的组织创建一个新租户。",
"confirmPassword": "确认密码",
"creatingAccount": "创建中...",
"register": "创建账号",
"loginFailed": "登录失败",
"inviteTitle": "接受邀请",
"inviteSubtitle": "您已被邀请加入",
"inviteRole": "角色:",
"inviteEmail": "邮箱:",
"yourName": "您的姓名",
"choosePassword": "设置密码",
"joining": "加入中...",
"joinOrganization": "加入组织",
"inviteInvalid": "此邀请无效或已过期。",
"backToLogin": "返回登录",
"validatingInvite": "验证邀请中..."
}

View File

@ -0,0 +1,89 @@
{
"save": "保存更改",
"saving": "保存中...",
"cancel": "取消",
"delete": "删除",
"deleting": "删除中...",
"edit": "编辑",
"create": "创建",
"creating": "创建中...",
"add": "添加",
"remove": "移除",
"close": "关闭",
"confirm": "确认",
"back": "返回",
"next": "下一步",
"loading": "加载中...",
"search": "搜索...",
"noResults": "未找到结果。",
"actions": "操作",
"name": "名称",
"type": "类型",
"status": "状态",
"description": "描述",
"created": "创建时间",
"updated": "更新时间",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"required": "必填",
"optional": "可选",
"enabled": "已启用",
"disabled": "已禁用",
"active": "活跃",
"inactive": "未激活",
"pending": "待处理",
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"yes": "是",
"no": "否",
"none": "无",
"all": "全部",
"id": "ID",
"email": "邮箱",
"role": "角色",
"roles": "角色",
"environment": "环境",
"tags": "标签",
"priority": "优先级",
"severity": "严重程度",
"details": "详情",
"configuration": "配置",
"view": "查看",
"refresh": "刷新",
"export": "导出",
"import": "导入",
"copy": "复制",
"copied": "已复制!",
"never": "从未",
"unknown": "未知",
"notSelected": "未选择",
"comingSoon": "即将推出",
"noData": "暂无数据。",
"confirmDelete": "确定要删除吗?",
"operationSuccess": "操作成功。",
"operationFailed": "操作失败。",
"passwordsNoMatch": "两次密码不一致。",
"riskLevel": {
"l0": "只读",
"l1": "低风险",
"l2": "高风险",
"l3": "禁止"
},
"approvalStatus": {
"pending": "待审批",
"approved": "已批准",
"rejected": "已拒绝",
"expired": "已过期"
},
"serverStatus": {
"active": "在线",
"inactive": "离线",
"maintenance": "维护中",
"unreachable": "不可达"
}
}

View File

@ -0,0 +1,106 @@
{
"title": "通信设置",
"tabs": {
"channels": "通知渠道",
"contacts": "联系人",
"escalationPolicies": "升级策略"
},
"channels": {
"title": "通知渠道",
"subtitle": "配置通知的发送方式。",
"types": {
"push": "推送通知",
"sms": "短信",
"voice": "语音呼叫",
"email": "邮件",
"telegram": "Telegram",
"wechatWork": "企业微信",
"voiceService": "语音服务"
},
"configured": "已配置",
"notConfigured": "未配置",
"showConfig": "显示配置",
"hideConfig": "隐藏配置",
"saveConfiguration": "保存配置",
"saving": "正在保存...",
"saveSuccess": "渠道配置已保存。",
"fields": {
"apiKey": "API 密钥",
"apiSecret": "API 密钥密码",
"fromNumber": "发送号码",
"webhookUrl": "Webhook URL",
"botToken": "Bot Token",
"chatId": "Chat ID",
"corpId": "企业 ID",
"agentId": "应用 ID",
"secret": "密钥",
"smtpHost": "SMTP 主机",
"smtpPort": "SMTP 端口",
"username": "用户名",
"password": "密码",
"fromAddress": "发件地址",
"endpoint": "端点",
"enabled": "启用"
}
},
"contacts": {
"title": "联系人",
"subtitle": "管理通知接收人。",
"addContact": "添加联系人",
"editContact": "编辑联系人",
"deleteContact": "删除联系人",
"empty": "暂无配置的联系人。",
"form": {
"name": "姓名",
"email": "邮箱",
"phone": "电话",
"role": "角色",
"channels": "首选渠道"
},
"table": {
"name": "姓名",
"email": "邮箱",
"phone": "电话",
"role": "角色",
"channels": "渠道",
"actions": "操作"
}
},
"escalationPolicies": {
"title": "升级策略",
"subtitle": "定义告警未确认时的升级方式。",
"addPolicy": "添加策略",
"editPolicy": "编辑策略",
"deletePolicy": "删除策略",
"empty": "暂无配置的升级策略。",
"editSteps": "编辑步骤",
"collapseSteps": "收起",
"form": {
"name": "名称",
"description": "描述",
"severity": "触发严重级别",
"steps": "升级步骤"
},
"step": {
"title": "步骤 {{number}}",
"delay": "延迟",
"delayMinutes": "分钟",
"channels": "渠道",
"contacts": "联系人",
"addStep": "添加步骤",
"removeStep": "移除步骤"
},
"severityOptions": {
"info": "信息",
"warning": "警告",
"critical": "严重",
"fatal": "致命"
},
"table": {
"name": "名称",
"severity": "严重级别",
"steps": "步骤数",
"actions": "操作"
}
}
}

View File

@ -0,0 +1,27 @@
{
"title": "仪表盘",
"subtitle": "系统概览和近期活动",
"stats": {
"totalServers": "服务器总数",
"activeAlerts": "活跃告警",
"runningTasks": "运行中任务",
"standingOrders": "常驻指令"
},
"recentAlerts": {
"title": "近期告警",
"severity": "严重级别",
"message": "告警信息",
"server": "服务器",
"time": "时间",
"empty": "暂无近期告警。",
"viewAll": "查看全部告警"
},
"recentTasks": {
"title": "近期任务",
"name": "任务名称",
"status": "状态",
"created": "创建时间",
"empty": "暂无近期任务。",
"viewAll": "查看全部任务"
}
}

View File

@ -0,0 +1,173 @@
{
"alertRules": {
"title": "告警规则",
"subtitle": "配置告警规则以监控服务器指标并接收通知。",
"addRule": "添加规则",
"editRule": "编辑规则",
"deleteRule": "删除告警规则",
"loading": "正在加载告警规则...",
"loadError": "加载告警规则失败:",
"empty": "暂无告警规则。",
"table": {
"name": "名称",
"metric": "指标",
"condition": "条件",
"threshold": "阈值",
"severity": "严重级别",
"enabled": "启用",
"actions": "操作"
},
"form": {
"name": "名称",
"description": "描述",
"metric": "指标",
"condition": "条件",
"threshold": "阈值",
"severity": "严重级别",
"notifyChannels": "通知渠道",
"notifyChannelsPlaceholder": "邮件、Slack、Webhook逗号分隔",
"cooldownMinutes": "冷却时间(分钟)",
"enabled": "启用"
},
"validation": {
"nameRequired": "名称不能为空",
"metricRequired": "指标不能为空",
"thresholdRequired": "请输入有效的阈值",
"cooldownRequired": "请输入有效的冷却时间"
},
"metrics": {
"cpuUsage": "CPU 使用率",
"memoryUsage": "内存使用率",
"diskUsage": "磁盘使用率",
"networkLatency": "网络延迟",
"httpErrorRate": "HTTP 错误率",
"custom": "自定义"
},
"conditions": {
"greaterThan": ">(大于)",
"lessThan": "<(小于)",
"equal": "==(等于)",
"greaterOrEqual": ">=(大于等于)",
"lessOrEqual": "<=(小于等于)"
},
"severities": {
"info": "信息",
"warning": "警告",
"critical": "严重",
"fatal": "致命"
},
"deleteConfirm": "确定要删除该规则吗?这将停止所有后续告警,且无法撤销。",
"detail": {
"backToAlertRules": "返回告警规则",
"ruleConfiguration": "规则配置",
"recentAlertEvents": "近期告警事件",
"ruleSummary": "规则摘要",
"notifyChannels": "通知渠道",
"quickActions": "快捷操作",
"metadata": "元数据",
"ruleExpression": "规则表达式",
"loading": "正在加载告警规则...",
"loadError": "加载告警规则失败:",
"noDescription": "无描述",
"noneConfigured": "未配置",
"noNotificationChannels": "未配置通知渠道。",
"noAlertEvents": "该规则尚未触发任何告警事件。",
"disableRule": "禁用规则",
"enableRule": "启用规则",
"editRule": "编辑规则",
"deleteRule": "删除规则",
"updating": "正在更新...",
"failedToUpdate": "更新规则失败:",
"ruleId": "规则 ID",
"cooldown": "冷却时间",
"totalEvents": "事件总数",
"eventTable": {
"severity": "严重级别",
"metric": "指标",
"value": "值",
"threshold": "阈值",
"message": "信息",
"status": "状态",
"triggered": "触发时间"
},
"eventStatus": {
"resolved": "已解决",
"active": "活跃"
}
}
},
"healthChecks": {
"title": "健康检查",
"subtitle": "监控服务器健康状态和可用性",
"loading": "正在加载健康检查...",
"loadError": "加载健康检查失败:",
"empty": "暂无健康检查。",
"noMatchingStatus": "未找到状态为「{{status}}」的服务器。",
"autoRefresh": "自动刷新:",
"live": "实时",
"refreshOptions": {
"off": "关闭",
"10s": "10秒",
"30s": "30秒",
"60s": "60秒"
},
"stats": {
"totalServers": "服务器总数",
"healthy": "健康",
"degraded": "降级",
"down": "宕机"
},
"filters": {
"all": "全部",
"healthy": "健康",
"degraded": "降级",
"down": "宕机"
},
"card": {
"latency": "延迟",
"uptime24h": "可用率24小时",
"lastCheck": "最近检查"
},
"checkTypes": {
"ping": "Ping",
"tcp": "TCP",
"http": "HTTP"
}
},
"metrics": {
"title": "指标面板",
"subtitle": "监控服务器性能和资源使用率",
"loading": "正在加载服务器指标...",
"loadError": "加载服务器指标失败:",
"empty": "暂无服务器指标数据。",
"noMatchingFilters": "没有匹配当前筛选条件的服务器。",
"autoRefresh": "自动刷新30秒",
"live": "实时",
"searchPlaceholder": "按主机名搜索...",
"showing": "显示 {{count}} / {{total}} 台服务器",
"overview": {
"totalServers": "服务器总数",
"online": "在线率",
"avgCpu": "平均 CPU",
"avgMemory": "平均内存",
"alertsToday": "今日告警"
},
"filters": {
"all": "全部",
"dev": "开发",
"staging": "预发布",
"prod": "生产",
"online": "在线",
"offline": "离线"
},
"table": {
"hostname": "主机名",
"env": "环境",
"status": "状态",
"cpuPercent": "CPU %",
"memoryPercent": "内存 %",
"diskPercent": "磁盘 %",
"lastChecked": "最近检查"
}
}
}

View File

@ -0,0 +1,75 @@
{
"title": "运维手册",
"subtitle": "管理运维手册和自动化脚本。",
"newRunbook": "新建手册",
"loading": "正在加载运维手册...",
"loadError": "加载运维手册失败:",
"empty": "暂无运维手册。",
"triggerTypes": {
"manual": "手动",
"alert": "告警触发",
"scheduled": "定时触发"
},
"riskLevels": {
"0": "L0 - 信息",
"1": "L1 - 低风险",
"2": "L2 - 中风险",
"3": "L3 - 高风险"
},
"form": {
"name": "名称",
"description": "描述",
"triggerType": "触发类型",
"promptTemplate": "提示词模板",
"allowedTools": "允许的工具",
"maxRiskLevel": "最大风险等级",
"autoApprove": "自动审批",
"enabled": "启用"
},
"table": {
"name": "名称",
"description": "描述",
"trigger": "触发方式",
"maxRisk": "最大风险",
"autoApprove": "自动审批",
"actions": "操作"
},
"deleteDialog": {
"title": "删除运维手册",
"message": "确定要删除该运维手册吗?此操作不可撤销。所有执行历史也将被移除。"
},
"detail": {
"backToRunbooks": "返回运维手册列表",
"back": "返回",
"overview": "概览",
"promptTemplate": "提示词模板",
"allowedTools": "允许的工具",
"executionHistory": "执行历史",
"configuration": "配置",
"quickActions": "快捷操作",
"loading": "正在加载运维手册...",
"loadError": "加载运维手册失败:",
"notFound": "运维手册未找到。",
"noDescription": "暂无描述。",
"noPrompt": "未定义提示词模板。",
"noTools": "未配置工具。",
"noExecutions": "暂无执行记录。",
"enableToExecute": "启用手册后才能执行。",
"triggerType": "触发类型",
"maxRiskLevel": "最大风险等级",
"autoApprove": "自动审批",
"autoApproveDescription": "在配置的风险等级内自动审批操作。",
"statusLabel": "状态",
"executeNow": "立即执行",
"executing": "正在执行...",
"duplicate": "复制",
"duplicating": "正在复制...",
"deleteRunbook": "删除运维手册",
"executionTable": {
"date": "日期",
"status": "状态",
"duration": "时长",
"triggeredBy": "触发者"
}
}
}

View File

@ -0,0 +1,151 @@
{
"title": "安全与风险规则",
"subtitle": "配置命令风险分类规则和安全策略。",
"riskRules": {
"title": "命令风险规则",
"addRule": "添加规则",
"editRule": "编辑规则",
"deleteRule": "删除规则",
"loading": "正在加载风险规则...",
"loadError": "加载风险规则失败:",
"empty": "暂无配置的风险规则。",
"table": {
"pattern": "匹配模式",
"riskLevel": "风险等级",
"action": "处理方式",
"description": "描述",
"actions": "操作"
},
"form": {
"pattern": "匹配模式",
"patternPlaceholder": "rm -rf *、sudo * 等",
"riskLevel": "风险等级",
"action": "处理方式",
"description": "描述"
},
"riskLevels": {
"0": "0 - 无风险",
"1": "1 - 低风险",
"2": "2 - 中风险",
"3": "3 - 高风险"
},
"actions": {
"allow": "允许",
"block": "阻止",
"requireApproval": "需要审批"
}
},
"permissionMatrix": {
"title": "权限矩阵",
"roles": {
"admin": "管理员",
"operator": "运维人员",
"viewer": "只读用户",
"readonly": "只读"
},
"permissions": {
"manageServers": "管理服务器",
"executeCommands": "执行命令",
"viewAudit": "查看审计",
"manageUsers": "管理用户",
"approveCommands": "审批命令"
},
"savePermissions": "保存权限"
},
"credentials": {
"title": "凭据",
"subtitle": "管理服务器连接的 SSH 凭据",
"addCredential": "添加凭据",
"editCredential": "编辑凭据",
"deleteCredential": "删除凭据",
"testCredential": "测试",
"loading": "正在加载凭据...",
"loadError": "加载凭据失败:",
"empty": "暂无凭据。",
"securityNotice": "凭据以加密方式存储,仅在使用时解密。",
"authTypes": {
"password": "密码",
"sshKey": "SSH 密钥",
"sshKeyPassphrase": "SSH 密钥 + 密码短语"
},
"table": {
"name": "名称",
"type": "类型",
"username": "用户名",
"associatedServers": "关联服务器",
"created": "创建时间",
"actions": "操作"
},
"form": {
"name": "名称",
"authType": "认证类型",
"username": "用户名",
"password": "密码",
"privateKey": "私钥",
"passphrase": "密码短语",
"servers": "关联服务器"
},
"encryptionWarning": "加密数据保存后无法检索。"
},
"roles": {
"title": "角色",
"subtitle": "管理角色及其关联的权限",
"addRole": "添加角色",
"editRole": "编辑角色",
"deleteRole": "删除角色",
"loading": "正在加载角色...",
"loadError": "加载角色失败:",
"empty": "暂无角色。",
"builtIn": "内置",
"custom": "自定义",
"showPerms": "权限",
"hidePerms": "隐藏权限",
"assignedPermissions": "已分配的权限",
"savingPermissions": "正在保存权限...",
"table": {
"name": "名称",
"description": "描述",
"type": "类型",
"permissions": "权限",
"users": "用户",
"created": "创建时间",
"actions": "操作"
},
"form": {
"name": "名称",
"description": "描述"
}
},
"permissions": {
"title": "权限",
"subtitle": "查看和管理各角色的权限矩阵",
"loading": "正在加载权限矩阵...",
"loadError": "加载权限失败:",
"empty": "暂无权限。",
"resource": "资源",
"permission": "权限",
"action": "操作",
"details": "权限详情",
"actionTypes": {
"create": "创建",
"read": "读取",
"update": "更新",
"delete": "删除",
"execute": "执行"
},
"summary": {
"title": "统计",
"totalPermissions": "权限总数",
"totalRoles": "角色总数",
"resources": "资源数",
"granted": "已授权"
},
"detailDialog": {
"title": "权限详情",
"key": "键",
"resource": "资源",
"action": "操作",
"description": "描述"
}
}
}

View File

@ -0,0 +1,97 @@
{
"title": "服务器",
"subtitle": "管理服务器资源、集群和 SSH 配置。",
"addServer": "添加服务器",
"editServer": "编辑服务器",
"deleteServer": "删除服务器",
"loading": "正在加载服务器...",
"loadError": "加载服务器失败:",
"empty": "暂无服务器。",
"filters": {
"all": "全部",
"dev": "开发",
"staging": "预发布",
"prod": "生产"
},
"table": {
"hostname": "主机名",
"host": "主机地址",
"environment": "环境",
"role": "角色",
"status": "状态",
"actions": "操作"
},
"form": {
"hostname": "主机名",
"hostIp": "主机地址IP",
"sshPort": "SSH 端口",
"environment": "环境",
"role": "角色",
"tags": "标签",
"description": "描述",
"tagsPlaceholder": "标签1, 标签2逗号分隔",
"descriptionPlaceholder": "可选描述..."
},
"validation": {
"hostnameRequired": "主机名不能为空",
"hostRequired": "主机地址不能为空"
},
"dialog": {
"deleteConfirm": "确定要删除",
"deleteWarning": "此操作不可撤销。该服务器及所有关联数据将被永久移除。"
},
"detail": {
"backToServers": "返回服务器列表",
"serverInformation": "服务器信息",
"recentHealthChecks": "近期健康检查",
"recentCommands": "近期命令",
"quickActions": "快捷操作",
"connectionInfo": "连接信息",
"tags": "标签",
"noTags": "未分配标签。",
"loading": "正在加载服务器详情...",
"loadError": "加载服务器失败:",
"noHealthChecks": "暂无健康检查记录。",
"noCommands": "该服务器上暂无已执行的命令。",
"saveChanges": "保存更改",
"openTerminal": "打开终端",
"runHealthCheck": "运行健康检查",
"editServer": "编辑服务器",
"deleteServer": "删除服务器",
"healthCheckTable": {
"status": "状态",
"latency": "延迟",
"message": "信息",
"time": "时间"
},
"commandsTable": {
"command": "命令",
"exitCode": "退出码",
"risk": "风险",
"time": "时间"
}
},
"clusters": {
"title": "集群",
"subtitle": "将服务器组织为逻辑分组",
"addCluster": "添加集群",
"editCluster": "编辑集群",
"deleteCluster": "删除集群",
"loading": "正在加载集群...",
"loadError": "加载集群失败:",
"empty": "暂无集群。",
"form": {
"name": "名称",
"description": "描述",
"environment": "环境",
"tags": "标签",
"servers": "服务器"
},
"health": {
"online": "在线",
"offline": "离线",
"maintenance": "维护中",
"noServers": "暂无服务器"
}
}
}

View File

@ -0,0 +1,46 @@
{
"title": "会话",
"subtitle": "查看和管理代理会话",
"loading": "正在加载会话...",
"loadError": "加载会话失败:",
"empty": "暂无会话。",
"statuses": {
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"cancelled": "已取消"
},
"detail": {
"title": "会话",
"backToSessions": "返回会话列表",
"sessionInformation": "会话信息",
"eventStream": "事件流",
"statistics": "统计",
"tasks": "任务",
"serverTargets": "目标服务器",
"timestamps": "时间戳",
"loading": "正在加载会话详情...",
"loadError": "加载会话失败:",
"autoScroll": "自动滚动",
"liveStreaming": "实时 -- 正在接收事件...",
"noEvents": "暂无事件记录。",
"noTasks": "此会话暂无任务。",
"info": {
"sessionId": "会话 ID",
"status": "状态",
"engine": "引擎",
"startedAt": "开始时间",
"endedAt": "结束时间",
"duration": "时长",
"tokenCount": "Token 数",
"totalCost": "总费用",
"task": "任务"
},
"engines": {
"claudeCli": "Claude Code CLI",
"claudeApi": "Claude API"
},
"inProgressDuration": "进行中...",
"inProgressStatus": "进行中"
}
}

View File

@ -0,0 +1,79 @@
{
"title": "设置",
"subtitle": "应用偏好和配置。",
"sections": {
"general": "通用",
"notifications": "通知",
"apikeys": "API 密钥",
"theme": "主题",
"account": "账户"
},
"general": {
"title": "通用设置",
"platformName": "平台名称",
"platformNamePlaceholder": "IT0 平台",
"defaultTimezone": "默认时区",
"defaultLanguage": "默认语言",
"uiLanguage": "界面语言",
"uiLanguageHint": "更改当前浏览器的显示语言。",
"autoApproveThreshold": "自动审批阈值(风险等级 0-3",
"autoApproveHint": "低于或等于此风险等级的操作将被自动批准。",
"saved": "设置已保存。"
},
"notifications": {
"title": "通知设置",
"email": "邮件通知",
"sms": "短信通知",
"push": "推送通知",
"escalationPolicy": "默认升级策略",
"saved": "通知设置已保存。"
},
"apikeys": {
"title": "API 密钥",
"namePlaceholder": "密钥名称(例如 CI/CD 流水线)",
"generate": "生成新密钥",
"generatedWarning": "新 API 密钥已生成。请立即复制 ——之后将无法再次查看。",
"copyToClipboard": "复制到剪贴板",
"dismiss": "关闭",
"headerName": "名称",
"headerKey": "密钥",
"headerCreated": "创建时间",
"headerLastUsed": "最后使用",
"headerActions": "操作",
"revoke": "吊销",
"noKeys": "暂无 API 密钥。在上方生成一个。"
},
"theme": {
"title": "主题",
"appearance": "外观",
"light": "浅色",
"dark": "深色",
"primaryColor": "主题色",
"customColor": "自定义颜色",
"selected": "已选:{{color}}",
"saveTheme": "保存主题",
"saved": "主题设置已保存。"
},
"account": {
"profileTitle": "账户资料",
"displayName": "显示名称",
"email": "邮箱",
"emailHint": "邮箱无法在此处修改。",
"saveProfile": "保存资料",
"profileSaved": "资料已更新。",
"changePassword": "修改密码",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码",
"passwordChanged": "密码修改成功。",
"changing": "修改中..."
},
"languages": {
"en": "English",
"zh": "中文",
"ko": "한국어",
"ja": "日本語",
"de": "Deutsch",
"fr": "Français"
}
}

View File

@ -0,0 +1,33 @@
{
"appName": "IT0 管理",
"appSubtitle": "运维控制台",
"dashboard": "仪表盘",
"agentConfig": "智能体配置",
"enginePrompt": "引擎与提示词",
"sdkConfig": "SDK 配置",
"skills": "技能",
"hooks": "钩子脚本",
"runbooks": "运维手册",
"standingOrders": "常驻指令",
"servers": "服务器",
"allServers": "全部服务器",
"clusters": "集群",
"monitoring": "监控",
"alertRules": "告警规则",
"healthChecks": "健康检查",
"metrics": "指标",
"terminal": "终端",
"security": "安全",
"riskRules": "风险规则",
"credentials": "凭证管理",
"roles": "角色",
"permissions": "权限",
"audit": "审计",
"logs": "日志",
"sessionReplay": "会话回放",
"communication": "通讯",
"tenants": "租户",
"users": "用户",
"settings": "设置",
"collapse": "折叠"
}

View File

@ -0,0 +1,111 @@
{
"title": "常驻指令",
"subtitle": "管理自主运维任务和执行计划。",
"newOrder": "新建常驻指令",
"loading": "正在加载常驻指令...",
"loadError": "加载常驻指令失败:",
"empty": "暂无常驻指令。",
"triggerTypes": {
"cron": "定时计划",
"event": "事件触发",
"threshold": "阈值触发"
},
"statuses": {
"active": "活跃",
"paused": "已暂停"
},
"form": {
"name": "名称",
"description": "描述",
"triggerType": "触发类型",
"triggerConfiguration": "触发配置",
"cronExpression": "Cron 表达式",
"eventType": "事件类型",
"metric": "指标",
"condition": "条件",
"thresholdValue": "阈值",
"targetServers": "目标服务器",
"targetServersPlaceholder": "server-1, server-2逗号分隔",
"agentInstruction": "代理指令",
"maxBudget": "最大预算(美元)",
"escalateOnFailure": "失败时升级"
},
"table": {
"name": "名称",
"trigger": "触发方式",
"status": "状态",
"lastExecution": "最近执行",
"nextRun": "下次运行",
"actions": "操作"
},
"executionHistory": {
"title": "执行历史",
"loading": "正在加载执行记录...",
"loadError": "加载执行记录失败:",
"empty": "暂无执行记录。",
"table": {
"date": "日期",
"status": "状态",
"duration": "时长",
"commands": "命令数",
"summary": "摘要",
"cost": "费用"
},
"executionStatuses": {
"running": "运行中",
"completed": "已完成",
"failed": "失败"
},
"executionSummary": "执行摘要",
"noSummary": "暂无摘要。",
"inProgress": "进行中...",
"started": "开始时间:",
"ended": "结束时间:"
},
"deleteDialog": {
"title": "删除常驻指令",
"message": "确定要删除该常驻指令吗?这将停止所有后续执行,且无法撤销。"
},
"detail": {
"backToStandingOrders": "返回常驻指令列表",
"overview": "概览",
"triggerConfiguration": "触发配置",
"agentInstruction": "代理指令",
"targetServers": "目标服务器",
"noTargetServers": "未配置目标服务器。",
"maxBudget": "最大预算",
"statusAndControls": "状态与控制",
"quickActions": "快捷操作",
"statistics": "统计",
"metadata": "元数据",
"loading": "正在加载常驻指令...",
"loadError": "加载常驻指令失败:",
"notFound": "常驻指令未找到。",
"pauseOrder": "暂停指令",
"activateOrder": "激活指令",
"executeNow": "立即执行",
"triggering": "正在触发...",
"executionTriggered": "已成功触发执行。",
"edit": "编辑",
"editStandingOrder": "编辑常驻指令",
"viewLogs": "查看日志",
"delete": "删除",
"escalateOnFailure": "失败时升级",
"lastExecution": "最近执行",
"nextRun": "下次运行",
"expression": "表达式:",
"eventType": "事件类型:",
"stats": {
"totalExecutions": "总执行次数",
"successRate": "成功率",
"avgDuration": "平均时长",
"lastFailure": "最近失败"
},
"pagination": {
"showing": "显示 {{from}} - {{to}},共 {{total}}",
"previous": "上一页",
"next": "下一页",
"pageOf": "第 {{current}} 页,共 {{total}} 页"
}
}
}

View File

@ -0,0 +1,107 @@
{
"title": "租户管理",
"subtitle": "管理租户、套餐和资源配额。",
"newTenant": "+ 新建租户",
"createTenant": "创建新租户",
"loading": "正在加载租户...",
"loadError": "加载租户失败:",
"empty": "暂无租户。创建第一个租户以开始使用。",
"form": {
"name": "名称",
"slug": "标识符",
"plan": "套餐",
"adminEmail": "管理员邮箱",
"status": "状态"
},
"plans": {
"free": "免费版",
"pro": "专业版",
"enterprise": "企业版"
},
"statuses": {
"active": "活跃",
"suspended": "已停用"
},
"table": {
"name": "名称",
"slug": "标识符",
"plan": "套餐",
"status": "状态",
"users": "用户数",
"created": "创建时间",
"actions": "操作"
},
"actions": {
"suspend": "停用",
"activate": "激活",
"quotas": "配额",
"hideQuotas": "隐藏配额"
},
"quotas": {
"title": "资源配额",
"maxServers": "最大服务器数",
"maxUsers": "最大用户数",
"maxStandingOrders": "最大常驻指令数",
"maxAgentTokens": "每月最大代理 Token 数"
},
"createDialog": {
"create": "创建租户",
"creating": "正在创建..."
},
"detail": {
"backToTenants": "返回租户列表",
"tenantInformation": "租户信息",
"members": "成员",
"invitations": "邀请",
"quickActions": "快捷操作",
"metadata": "元数据",
"loading": "正在加载租户详情...",
"loadError": "加载租户失败:",
"failedToUpdate": "更新租户失败:",
"schemaName": "Schema 名称",
"memberCount": "成员数",
"noMembers": "暂无成员。",
"noInvitations": "尚未发送任何邀请。",
"inviteUser": "+ 邀请用户",
"sendInvite": "发送邀请",
"sending": "正在发送...",
"revoke": "撤销",
"suspendTenant": "停用租户",
"activateTenant": "激活租户",
"viewAuditLog": "查看审计日志",
"editTenant": "编辑租户",
"deleteTenant": "删除租户",
"tenantId": "租户 ID",
"slug": "标识符",
"schema": "Schema",
"membersTable": {
"name": "姓名",
"email": "邮箱",
"role": "角色",
"joined": "加入时间"
},
"invitesTable": {
"email": "邮箱",
"role": "角色",
"status": "状态",
"actions": "操作"
},
"inviteForm": {
"email": "邮箱",
"emailPlaceholder": "user@example.com",
"role": "角色",
"roles": {
"viewer": "只读用户",
"operator": "运维人员",
"admin": "管理员"
}
},
"deleteDialog": {
"title": "删除租户",
"message": "确定要删除该租户吗?这将永久移除该租户的所有数据和成员关联关系。此操作不可撤销。"
},
"validation": {
"nameRequired": "名称不能为空"
}
}
}

View File

@ -0,0 +1,35 @@
{
"title": "终端",
"subtitle": "远程 Shell 访问",
"connection": {
"connected": "已连接",
"connecting": "正在连接...",
"disconnected": "已断开"
},
"server": {
"label": "服务器:",
"selectServer": "选择服务器",
"loadingServers": "正在加载服务器..."
},
"actions": {
"connect": "连接",
"disconnect": "断开",
"clear": "清屏"
},
"noServerSelected": "未选择服务器",
"selectServerPrompt": "选择一台服务器并点击连接以启动终端会话。",
"messages": {
"connected": "已连接到 {{server}}",
"disconnected": "已断开与服务器的连接。",
"connectionFailed": "连接失败:",
"reconnecting": "正在重新连接...",
"sessionTimeout": "会话已超时。"
},
"shortcuts": {
"title": "键盘快捷键",
"sendCommand": "发送命令",
"commandHistory": "命令历史",
"clearTerminal": "清除终端"
},
"placeholder": "输入命令..."
}

View File

@ -0,0 +1,10 @@
{
"search": "搜索...",
"shortcutKey": "⌘K",
"tenant": "租户:",
"notSelected": "未选择",
"settings": "设置",
"users": "用户",
"signOut": "退出登录",
"defaultUser": "用户"
}

View File

@ -0,0 +1,89 @@
{
"title": "用户",
"subtitle": "管理用户账户和访问权限",
"addUser": "添加用户",
"loading": "正在加载用户...",
"loadError": "加载用户失败:",
"empty": "暂无用户。添加一个用户以开始使用。",
"noMatchingFilters": "没有匹配当前筛选条件的用户。",
"showing": "显示 {{count}} / {{total}} 个用户",
"searchPlaceholder": "按姓名或邮箱搜索...",
"allRoles": "全部角色",
"roles": {
"admin": "管理员",
"operator": "运维人员",
"viewer": "只读用户"
},
"statuses": {
"active": "活跃",
"disabled": "已禁用"
},
"table": {
"name": "姓名",
"email": "邮箱",
"role": "角色",
"tenant": "租户",
"status": "状态",
"lastLogin": "最近登录",
"created": "创建时间",
"actions": "操作"
},
"form": {
"displayName": "显示名称",
"email": "邮箱",
"password": "密码",
"role": "角色",
"tenantId": "租户 ID"
},
"createDialog": {
"title": "添加用户",
"create": "创建用户",
"creating": "正在创建..."
},
"editDialog": {
"title": "编辑用户:",
"save": "保存更改",
"saving": "正在保存..."
},
"deleteDialog": {
"title": "删除用户",
"message": "确定要删除该用户吗?此操作不可撤销。",
"warning": "该用户将失去所有访问权限,其会话将被立即终止。"
},
"validation": {
"displayNameRequired": "显示名称不能为空",
"emailRequired": "邮箱不能为空",
"passwordRequired": "密码不能为空"
},
"detail": {
"backToUsers": "返回用户列表",
"userInformation": "用户信息",
"activityLog": "活动日志",
"quickActions": "快捷操作",
"accountSummary": "账户摘要",
"loading": "正在加载用户详情...",
"loadError": "加载用户失败:",
"failedToUpdate": "更新用户失败:",
"noActivity": "暂无活动记录。",
"editUser": "编辑用户",
"resetPassword": "重置密码",
"deleteUser": "删除用户",
"memberSince": "注册时间",
"lastLogin": "最近登录",
"activityTable": {
"action": "操作",
"resource": "资源",
"details": "详情",
"ipAddress": "IP 地址",
"time": "时间"
},
"resetPasswordDialog": {
"title": "重置密码",
"message": "这将向该用户的邮箱发送密码重置链接。用户需要设置新密码。",
"sendResetLink": "发送重置链接",
"sending": "正在发送...",
"success": "密码重置链接已发送至用户邮箱。",
"done": "完成"
}
}
}

View File

@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState, createContext, useContext } from 'react'; import { useState, useMemo, createContext, useContext } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
LayoutDashboard, LayoutDashboard,
@ -17,6 +18,7 @@ import {
MessageSquare, MessageSquare,
Building2, Building2,
Settings, Settings,
Users,
ChevronRight, ChevronRight,
PanelLeftClose, PanelLeftClose,
PanelLeft, PanelLeft,
@ -39,6 +41,7 @@ export const useSidebar = () => useContext(SidebarContext);
/* ---------- Nav config ---------- */ /* ---------- Nav config ---------- */
interface NavItem { interface NavItem {
key: string;
label: string; label: string;
href: string; href: string;
icon: React.ReactNode; icon: React.ReactNode;
@ -47,63 +50,6 @@ interface NavItem {
const iconClass = 'h-4 w-4 shrink-0'; const iconClass = 'h-4 w-4 shrink-0';
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> },
{
label: 'Agent Config',
href: '/agent-config',
icon: <Bot className={iconClass} />,
children: [
{ label: 'Engine & Prompt', href: '/agent-config' },
{ label: 'SDK Config', href: '/agent-config/sdk' },
{ label: 'Skills', href: '/agent-config/skills' },
{ label: 'Hooks', href: '/agent-config/hooks' },
],
},
{ label: 'Runbooks', href: '/runbooks', icon: <BookOpen className={iconClass} /> },
{ label: 'Standing Orders', href: '/standing-orders', icon: <ClipboardList className={iconClass} /> },
{
label: 'Servers',
href: '/servers',
icon: <Server className={iconClass} />,
children: [
{ label: 'All Servers', href: '/servers' },
{ label: 'Clusters', href: '/servers/clusters' },
],
},
{
label: 'Monitoring',
href: '/monitoring/alert-rules',
icon: <Activity className={iconClass} />,
children: [
{ label: 'Alert Rules', href: '/monitoring/alert-rules' },
{ label: 'Health Checks', href: '/monitoring/health-checks' },
],
},
{ label: 'Terminal', href: '/terminal', icon: <Terminal className={iconClass} /> },
{
label: 'Security',
href: '/security/risk-rules',
icon: <Shield className={iconClass} />,
children: [
{ label: 'Risk Rules', href: '/security/risk-rules' },
{ label: 'Credentials', href: '/security/credentials' },
],
},
{
label: 'Audit',
href: '/audit/logs',
icon: <FileSearch className={iconClass} />,
children: [
{ label: 'Logs', href: '/audit/logs' },
{ label: 'Session Replay', href: '/audit/replay' },
],
},
{ label: 'Communication', href: '/communication', icon: <MessageSquare className={iconClass} /> },
{ label: 'Tenants', href: '/tenants', icon: <Building2 className={iconClass} /> },
{ label: 'Settings', href: '/settings', icon: <Settings className={iconClass} /> },
];
/* ---------- Helpers ---------- */ /* ---------- Helpers ---------- */
function isActive(pathname: string, href: string) { function isActive(pathname: string, href: string) {
@ -137,11 +83,78 @@ function Tooltip({ children, label }: { children: React.ReactNode; label: string
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useTranslation('sidebar');
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const toggle = (label: string) => const navItems: NavItem[] = useMemo(() => [
setExpanded((prev) => ({ ...prev, [label]: !prev[label] })); { key: 'dashboard', label: t('dashboard'), href: '/dashboard', icon: <LayoutDashboard className={iconClass} /> },
{
key: 'agentConfig',
label: t('agentConfig'),
href: '/agent-config',
icon: <Bot className={iconClass} />,
children: [
{ label: t('enginePrompt'), href: '/agent-config' },
{ label: t('sdkConfig'), href: '/agent-config/sdk' },
{ label: t('skills'), href: '/agent-config/skills' },
{ label: t('hooks'), href: '/agent-config/hooks' },
],
},
{ key: 'runbooks', label: t('runbooks'), href: '/runbooks', icon: <BookOpen className={iconClass} /> },
{ key: 'standingOrders', label: t('standingOrders'), href: '/standing-orders', icon: <ClipboardList className={iconClass} /> },
{
key: 'servers',
label: t('servers'),
href: '/servers',
icon: <Server className={iconClass} />,
children: [
{ label: t('allServers'), href: '/servers' },
{ label: t('clusters'), href: '/servers/clusters' },
],
},
{
key: 'monitoring',
label: t('monitoring'),
href: '/monitoring/alert-rules',
icon: <Activity className={iconClass} />,
children: [
{ label: t('alertRules'), href: '/monitoring/alert-rules' },
{ label: t('healthChecks'), href: '/monitoring/health-checks' },
{ label: t('metrics'), href: '/monitoring/metrics' },
],
},
{ key: 'terminal', label: t('terminal'), href: '/terminal', icon: <Terminal className={iconClass} /> },
{
key: 'security',
label: t('security'),
href: '/security/risk-rules',
icon: <Shield className={iconClass} />,
children: [
{ label: t('riskRules'), href: '/security/risk-rules' },
{ label: t('credentials'), href: '/security/credentials' },
{ label: t('roles'), href: '/security/roles' },
{ label: t('permissions'), href: '/security/permissions' },
],
},
{
key: 'audit',
label: t('audit'),
href: '/audit/logs',
icon: <FileSearch className={iconClass} />,
children: [
{ label: t('logs'), href: '/audit/logs' },
{ label: t('sessionReplay'), href: '/audit/replay' },
],
},
{ key: 'communication', label: t('communication'), href: '/communication', icon: <MessageSquare className={iconClass} /> },
{ key: 'tenants', label: t('tenants'), href: '/tenants', icon: <Building2 className={iconClass} /> },
{ key: 'users', label: t('users'), href: '/users', icon: <Users className={iconClass} /> },
{ key: 'settings', label: t('settings'), href: '/settings', icon: <Settings className={iconClass} /> },
], [t]);
const toggle = (key: string) =>
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
return ( return (
<SidebarContext.Provider value={{ collapsed, setCollapsed }}> <SidebarContext.Provider value={{ collapsed, setCollapsed }}>
@ -159,8 +172,8 @@ export function Sidebar() {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-sm font-semibold tracking-tight truncate">IT0 Admin</h1> <h1 className="text-sm font-semibold tracking-tight truncate">{t('appName')}</h1>
<p className="text-[10px] text-muted-foreground leading-none mt-0.5">Operations Console</p> <p className="text-[10px] text-muted-foreground leading-none mt-0.5">{t('appSubtitle')}</p>
</div> </div>
)} )}
</div> </div>
@ -171,7 +184,7 @@ export function Sidebar() {
{navItems.map((item) => { {navItems.map((item) => {
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const groupActive = isGroupActive(pathname, item); const groupActive = isGroupActive(pathname, item);
const isOpen = expanded[item.label] ?? groupActive; const isOpen = expanded[item.key] ?? groupActive;
/* ---- Collapsed: icon-only with tooltip ---- */ /* ---- Collapsed: icon-only with tooltip ---- */
if (collapsed) { if (collapsed) {
@ -179,7 +192,7 @@ export function Sidebar() {
? item.children![0].href ? item.children![0].href
: item.href; : item.href;
return ( return (
<Tooltip key={item.label} label={item.label}> <Tooltip key={item.key} label={item.label}>
<Link <Link
href={linkTarget} href={linkTarget}
className={cn( className={cn(
@ -199,7 +212,7 @@ export function Sidebar() {
if (!hasChildren) { if (!hasChildren) {
return ( return (
<Link <Link
key={item.href} key={item.key}
href={item.href} href={item.href}
className={cn( className={cn(
'flex items-center gap-2.5 px-2.5 py-2 rounded-md text-[13px] transition-colors', 'flex items-center gap-2.5 px-2.5 py-2 rounded-md text-[13px] transition-colors',
@ -216,9 +229,9 @@ export function Sidebar() {
/* ---- Expanded: with children ---- */ /* ---- Expanded: with children ---- */
return ( return (
<div key={item.label}> <div key={item.key}>
<button <button
onClick={() => toggle(item.label)} onClick={() => toggle(item.key)}
className={cn( className={cn(
'flex items-center gap-2.5 w-full px-2.5 py-2 rounded-md text-[13px] transition-colors', 'flex items-center gap-2.5 w-full px-2.5 py-2 rounded-md text-[13px] transition-colors',
groupActive groupActive
@ -273,7 +286,7 @@ export function Sidebar() {
) : ( ) : (
<> <>
<PanelLeftClose className="h-4 w-4 shrink-0" /> <PanelLeftClose className="h-4 w-4 shrink-0" />
<span>Collapse</span> <span>{t('collapse')}</span>
</> </>
)} )}
</button> </button>

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useTenantStore } from '@/stores/zustand/tenant-store'; import { useTenantStore } from '@/stores/zustand/tenant-store';
import { Search, Bell, LogOut, User, Settings } from 'lucide-react'; import { Search, Bell, LogOut, User, Settings } from 'lucide-react';
@ -14,6 +15,7 @@ interface StoredUser {
export function TopBar() { export function TopBar() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation('topbar');
const { currentTenant } = useTenantStore(); const { currentTenant } = useTenantStore();
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [user, setUser] = useState<StoredUser | null>(null); const [user, setUser] = useState<StoredUser | null>(null);
@ -61,9 +63,9 @@ export function TopBar() {
}} }}
> >
<Search className="h-3.5 w-3.5 shrink-0" /> <Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 text-left">Search...</span> <span className="flex-1 text-left">{t('search')}</span>
<kbd className="text-[10px] bg-muted/60 px-1.5 py-0.5 rounded border border-border/40 font-mono"> <kbd className="text-[10px] bg-muted/60 px-1.5 py-0.5 rounded border border-border/40 font-mono">
K {t('shortcutKey')}
</kbd> </kbd>
</button> </button>
@ -79,8 +81,8 @@ export function TopBar() {
{/* Tenant indicator */} {/* Tenant indicator */}
<div className="text-xs"> <div className="text-xs">
<span className="text-muted-foreground">Tenant: </span> <span className="text-muted-foreground">{t('tenant')} </span>
<span className="font-medium">{currentTenant?.name || currentTenant?.id || 'Not selected'}</span> <span className="font-medium">{currentTenant?.name || currentTenant?.id || t('notSelected')}</span>
</div> </div>
{/* User avatar + dropdown */} {/* User avatar + dropdown */}
@ -96,7 +98,7 @@ export function TopBar() {
<div className="absolute right-0 top-full mt-1.5 w-56 bg-card border rounded-lg shadow-lg py-1 z-50"> <div className="absolute right-0 top-full mt-1.5 w-56 bg-card border rounded-lg shadow-lg py-1 z-50">
{/* User info */} {/* User info */}
<div className="px-3 py-2 border-b"> <div className="px-3 py-2 border-b">
<p className="text-sm font-medium truncate">{user?.name || 'User'}</p> <p className="text-sm font-medium truncate">{user?.name || t('defaultUser')}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p> <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div> </div>
@ -106,14 +108,14 @@ export function TopBar() {
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
Settings {t('settings')}
</button> </button>
<button <button
onClick={() => { setMenuOpen(false); router.push('/users'); }} onClick={() => { setMenuOpen(false); router.push('/users'); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
<User className="h-3.5 w-3.5" /> <User className="h-3.5 w-3.5" />
Users {t('users')}
</button> </button>
<div className="border-t my-1" /> <div className="border-t my-1" />
@ -123,7 +125,7 @@ export function TopBar() {
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
> >
<LogOut className="h-3.5 w-3.5" /> <LogOut className="h-3.5 w-3.5" />
Sign out {t('signOut')}
</button> </button>
</div> </div>
)} )}

View File

@ -0,0 +1,39 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type SupportedLocale = 'en' | 'zh';
interface LocaleState {
locale: SupportedLocale;
setLocale: (locale: SupportedLocale) => void;
}
export const useLocaleStore = create<LocaleState>()(
persist(
(set) => ({
locale: 'en',
setLocale: (locale) => {
import('@/i18n/config').then(({ default: i18n }) => {
i18n.changeLanguage(locale);
});
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
set({ locale });
},
}),
{ name: 'it0-locale' },
),
);
// Sync i18next with persisted locale on app load.
// Called once from providers.tsx after i18n/config is initialized.
export function syncLocaleOnLoad() {
const { locale } = useLocaleStore.getState();
if (locale && typeof document !== 'undefined') {
import('@/i18n/config').then(({ default: i18n }) => {
i18n.changeLanguage(locale);
});
document.documentElement.lang = locale;
}
}