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:
parent
a7c6aae8c6
commit
660616b08b
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "Add Hook" 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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'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'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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
← Back to Skills
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to Skills
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to Skills
|
← {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"
|
||||||
>
|
>
|
||||||
← Back
|
← {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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 "Add Contact" 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 "Add Policy" 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
← Back to Runbooks
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to Runbooks
|
← {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"
|
||||||
>
|
>
|
||||||
← Back
|
← {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>
|
||||||
|
|
|
||||||
|
|
@ -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 "New Runbook" 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
← Back to Standing Orders
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to Standing Orders
|
← {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"
|
||||||
>
|
>
|
||||||
← Back to Standing Orders
|
← {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>
|
||||||
|
|
|
||||||
|
|
@ -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 "New Standing Order" 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>'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>'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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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..."
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"search": "Search...",
|
||||||
|
"shortcutKey": "⌘K",
|
||||||
|
"tenant": "Tenant:",
|
||||||
|
"notSelected": "Not selected",
|
||||||
|
"settings": "Settings",
|
||||||
|
"users": "Users",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"defaultUser": "User"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "审批时"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "会话已完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "验证邀请中..."
|
||||||
|
}
|
||||||
|
|
@ -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": "不可达"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "操作"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "查看全部任务"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "最近检查"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "触发者"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "描述"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "暂无服务器"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "进行中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "折叠"
|
||||||
|
}
|
||||||
|
|
@ -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}} 页"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "名称不能为空"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "输入命令..."
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"search": "搜索...",
|
||||||
|
"shortcutKey": "⌘K",
|
||||||
|
"tenant": "租户:",
|
||||||
|
"notSelected": "未选择",
|
||||||
|
"settings": "设置",
|
||||||
|
"users": "用户",
|
||||||
|
"signOut": "退出登录",
|
||||||
|
"defaultUser": "用户"
|
||||||
|
}
|
||||||
|
|
@ -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": "完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue