feat: add internal admin flows and fixture runner
This commit is contained in:
parent
9cd27caa24
commit
cfdc997211
@ -1,4 +1,8 @@
|
||||
# Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/.
|
||||
# Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead.
|
||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
||||
# Label for this Web instance: development, test, or production. Set test when using ai_platform_test.
|
||||
WEB_ENVIRONMENT=development
|
||||
# Optional server-only key for the internal fixture runner (use a separate test tenant).
|
||||
# LEAD_SCORING_FIXTURE_TENANT_KEY=
|
||||
# Never put credentials or backend URLs in PUBLIC_* variables.
|
||||
|
||||
@ -5,6 +5,7 @@ COPY apps/web/package.json apps/web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY apps/web/ ./
|
||||
COPY contracts/lead-scoring/v2/openapi.json /contracts/lead-scoring/v2/openapi.json
|
||||
COPY contracts/lead-scoring/v2/fixtures/run-request.json /contracts/lead-scoring/v2/fixtures/run-request.json
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1.4.0 AS runtime
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import ListChecks from '@lucide/svelte/icons/list-checks';
|
||||
import FlaskConical from '@lucide/svelte/icons/flask-conical';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ChartNoAxesCombined from '@lucide/svelte/icons/chart-no-axes-combined';
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
|
||||
let { email }: { email: string } = $props();
|
||||
const sidebar = useSidebar();
|
||||
@ -39,30 +39,25 @@
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content class="py-3">
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>DỊCH VỤ AI</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupLabel>NỀN TẢNG</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<span
|
||||
class="flex min-h-9 items-center gap-2 px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:justify-center"
|
||||
title="Lead Scoring"
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent="Mã truy cập"
|
||||
isActive={page.url.pathname.startsWith('/platform/tenant-keys')}
|
||||
>
|
||||
<ChartNoAxesCombined class="size-4 shrink-0" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">Lead Scoring</span>
|
||||
</span>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<span
|
||||
class="flex min-h-9 items-center gap-2 px-2 text-sm text-muted-foreground group-data-[collapsible=icon]:justify-center"
|
||||
title="Churn: chưa có đặc tả"
|
||||
>
|
||||
<ChartNoAxesCombined class="size-4 shrink-0 opacity-50" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">Churn</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="ml-auto text-[10px] group-data-[collapsible=icon]:hidden"
|
||||
>Chưa có đặc tả</Badge
|
||||
>
|
||||
</span>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href="/platform/tenant-keys"
|
||||
aria-current={page.url.pathname.startsWith('/platform/tenant-keys')
|
||||
? 'page'
|
||||
: undefined}
|
||||
>
|
||||
<KeyRound /> <span>Mã truy cập</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
@ -88,6 +83,24 @@
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent="Thử quy tắc"
|
||||
isActive={page.url.pathname.startsWith('/lead-scoring/fixtures')}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href="/lead-scoring/fixtures"
|
||||
aria-current={page.url.pathname.startsWith('/lead-scoring/fixtures')
|
||||
? 'page'
|
||||
: undefined}
|
||||
>
|
||||
<FlaskConical /> <span>Thử quy tắc</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent="Cấu hình AI"
|
||||
|
||||
40
apps/web/src/lib/server/fixture-runner.test.ts
Normal file
40
apps/web/src/lib/server/fixture-runner.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { fixtureRunnerConfigured, submitScoringFixture } from './fixture-runner';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
BACKEND_INTERNAL_URL: 'http://backend:8000',
|
||||
LEAD_SCORING_FIXTURE_TENANT_KEY: 'test-only-tenant-key',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('server-only fixture transport', () => {
|
||||
it('sends the canonical fixture with a new request ID and no V-Key', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 }));
|
||||
const event = { fetch } as unknown as RequestEvent;
|
||||
expect(fixtureRunnerConfigured()).toBe(true);
|
||||
|
||||
await submitScoringFixture(event);
|
||||
await submitScoringFixture(event);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const ids = fetch.mock.calls.map(([url, init]) => {
|
||||
expect(url).toBe('http://backend:8000/v1/lead-scoring/runs');
|
||||
expect(init.headers.authorization).toBe('Bearer test-only-tenant-key');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers['Idempotency-Key']).toBe(body.request_id);
|
||||
expect(init.headers).not.toHaveProperty('X-Tenant-VKey');
|
||||
expect(init.cache).toBe('no-store');
|
||||
expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_TENANT_KEY);
|
||||
expect(body.subject_ref).toBe('ems-lead-123');
|
||||
expect(body.scoring_config.ai_weight).toBe(0);
|
||||
return body.request_id;
|
||||
});
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
expect(ids.every((id: string) => /^[0-9a-f-]{36}$/.test(id))).toBe(true);
|
||||
});
|
||||
});
|
||||
37
apps/web/src/lib/server/fixture-runner.ts
Normal file
37
apps/web/src/lib/server/fixture-runner.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import fixture from '../../../../../contracts/lead-scoring/v2/fixtures/run-request.json';
|
||||
|
||||
export type RunRequest = components['schemas']['RunRequest'];
|
||||
|
||||
export class FixtureRunnerNotConfigured extends Error {}
|
||||
|
||||
export function fixtureRunnerConfigured(): boolean {
|
||||
return Boolean(env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim());
|
||||
}
|
||||
|
||||
export async function submitScoringFixture(event: RequestEvent): Promise<Response> {
|
||||
const base = env.BACKEND_INTERNAL_URL;
|
||||
const tenantKey = env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim();
|
||||
if (!tenantKey) throw new FixtureRunnerNotConfigured();
|
||||
if (!base || !/^https?:\/\//.test(base)) {
|
||||
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
|
||||
}
|
||||
|
||||
const request: RunRequest = {
|
||||
...(fixture as RunRequest),
|
||||
request_id: crypto.randomUUID(),
|
||||
};
|
||||
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/runs`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${tenantKey}`,
|
||||
'content-type': 'application/json',
|
||||
'Idempotency-Key': request.request_id,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { backendJson, clearAdminSession } from '$lib/server/backend';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
const environmentLabels: Record<string, string> = {
|
||||
test: 'TEST · dữ liệu thử',
|
||||
development: 'DEV · dữ liệu phát triển',
|
||||
production: 'PROD · dữ liệu thật',
|
||||
};
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
if (!event.cookies.get('ap_session')) redirect(303, '/login');
|
||||
const { response, data } = await backendJson<{ email: string }>(event, '/v1/admin/me');
|
||||
@ -9,5 +16,8 @@ export const load: LayoutServerLoad = async (event) => {
|
||||
clearAdminSession(event);
|
||||
redirect(303, '/login');
|
||||
}
|
||||
return { admin: data };
|
||||
return {
|
||||
admin: data,
|
||||
environment: environmentLabels[env.WEB_ENVIRONMENT ?? ''] ?? 'Môi trường chưa xác định',
|
||||
};
|
||||
};
|
||||
|
||||
@ -17,8 +17,9 @@
|
||||
<Sidebar.Trigger aria-label="Ẩn hoặc hiện điều hướng" />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<span class="text-sm font-semibold">DotB AI Platform</span>
|
||||
<span class="ml-auto hidden text-xs text-muted-foreground sm:inline"
|
||||
>Quản trị nội bộ</span
|
||||
<span
|
||||
class="ml-auto rounded-md border border-border bg-muted px-2 py-1 text-xs font-semibold text-foreground"
|
||||
>{data.environment}</span
|
||||
>
|
||||
</header>
|
||||
<div class="mx-auto w-full max-w-7xl px-5 py-8 md:px-8 md:py-10" id="main-content">
|
||||
|
||||
@ -4,10 +4,12 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export type AIProfile = {
|
||||
version: string;
|
||||
provider: 'aihub' | 'openai_compatible';
|
||||
model: string;
|
||||
system_prompt: string;
|
||||
timeout_seconds: number;
|
||||
active: boolean;
|
||||
base_url: string | null;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
@ -23,19 +25,41 @@ export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const version = String(form.get('version') ?? '').trim();
|
||||
const provider = String(form.get('provider') ?? 'aihub');
|
||||
const base_url =
|
||||
provider === 'openai_compatible' ? String(form.get('base_url') ?? '').trim() : null;
|
||||
const model = String(form.get('model') ?? '').trim();
|
||||
const system_prompt = String(form.get('system_prompt') ?? '').trim();
|
||||
const timeout_seconds = Number(form.get('timeout_seconds'));
|
||||
if (!version || !model || !system_prompt || !Number.isFinite(timeout_seconds)) {
|
||||
return fail(400, { message: 'Điền đầy đủ phiên bản, model, prompt và timeout.' });
|
||||
if (
|
||||
!version ||
|
||||
!model ||
|
||||
!system_prompt ||
|
||||
!Number.isFinite(timeout_seconds) ||
|
||||
(provider === 'openai_compatible' && !base_url) ||
|
||||
!['aihub', 'openai_compatible'].includes(provider)
|
||||
) {
|
||||
return fail(400, {
|
||||
message:
|
||||
'Nhập đủ phiên bản, nhà cung cấp, URL (nếu dùng OpenAI-compatible), model và prompt.',
|
||||
});
|
||||
}
|
||||
const response = await backend(event, '/v1/admin/ai-profiles', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ version, model, system_prompt, timeout_seconds }),
|
||||
body: JSON.stringify({
|
||||
version,
|
||||
provider,
|
||||
base_url,
|
||||
model,
|
||||
system_prompt,
|
||||
timeout_seconds,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409)
|
||||
return fail(409, { message: 'Phiên bản đã tồn tại với nội dung khác.' });
|
||||
if (response.status === 422)
|
||||
return fail(422, { message: 'URL không hợp lệ hoặc nhà cung cấp chưa sẵn sàng.' });
|
||||
if (!response.ok) return fail(response.status, { message: 'Không lưu được cấu hình AI.' });
|
||||
return { message: 'Đã kích hoạt phiên bản mới.', success: true };
|
||||
},
|
||||
|
||||
@ -9,6 +9,11 @@
|
||||
import * as Resizable from '$lib/components/ui/resizable/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let selectedProvider = $state<PageProps['data']['profiles'][number]['provider']>('aihub');
|
||||
const providerLabels: Record<PageProps['data']['profiles'][number]['provider'], string> = {
|
||||
aihub: 'AIHub',
|
||||
openai_compatible: 'OpenAI-compatible',
|
||||
};
|
||||
</script>
|
||||
|
||||
{#snippet versionList()}
|
||||
@ -24,9 +29,14 @@
|
||||
{#each data.profiles as profile (profile.version)}
|
||||
<li class="flex flex-wrap items-center gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<strong class="text-sm">{profile.version}</strong>
|
||||
<Badge variant="outline">{providerLabels[profile.provider]}</Badge>
|
||||
<span class="min-w-0 break-all text-sm text-muted-foreground"
|
||||
>{profile.model}</span
|
||||
>
|
||||
{#if profile.base_url}<span
|
||||
class="w-full break-all text-xs text-muted-foreground"
|
||||
>{profile.base_url}</span
|
||||
>{/if}
|
||||
{#if profile.active}<Badge variant="secondary" class="ml-auto"
|
||||
>Đang dùng</Badge
|
||||
>{/if}
|
||||
@ -42,9 +52,7 @@
|
||||
<Card.Root class="h-full">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tạo và kích hoạt phiên bản</h2>
|
||||
<Card.Description
|
||||
>Tạo phiên bản mới thay vì sửa lịch sử của các lần chấm điểm đã chạy.</Card.Description
|
||||
>
|
||||
<Card.Description>Mỗi lần đổi cấu hình, tạo phiên bản mới.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" class="grid gap-4">
|
||||
@ -58,11 +66,38 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-model`}>Model AIHub</Label><Input
|
||||
<Label for={`${prefix}-provider`}>Nhà cung cấp</Label>
|
||||
<select
|
||||
id={`${prefix}-provider`}
|
||||
name="provider"
|
||||
bind:value={selectedProvider}
|
||||
required
|
||||
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<option value="aihub">{providerLabels.aihub}</option>
|
||||
<option value="openai_compatible">{providerLabels.openai_compatible}</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
{#if selectedProvider === 'openai_compatible'}
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-base-url`}>URL OpenAI-compatible</Label>
|
||||
<Input
|
||||
id={`${prefix}-base-url`}
|
||||
name="base_url"
|
||||
type="url"
|
||||
required
|
||||
maxlength={2048}
|
||||
placeholder="http://127.0.0.1:8317"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-model`}>Model của nhà cung cấp</Label><Input
|
||||
id={`${prefix}-model`}
|
||||
name="model"
|
||||
required
|
||||
value="openai/gpt-4o-mini"
|
||||
placeholder="Nhập model của nhà cung cấp"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
@ -79,7 +114,7 @@
|
||||
name="timeout_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
max="120"
|
||||
value="12"
|
||||
required
|
||||
/>
|
||||
@ -100,8 +135,7 @@
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Cấu hình</p>
|
||||
<h1 class="text-3xl font-bold">Cấu hình AI</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Quản lý model, prompt và thời gian chờ của AI Platform. Omni sở hữu quy tắc và trọng số;
|
||||
thông tin xác thực nhà cung cấp, gồm V-Key, do AIHub quản lý và không nhập tại đây.
|
||||
Chọn nhà cung cấp, nhập URL nếu dùng OpenAI-compatible và chọn model cho Lead Scoring.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid gap-5 lg:hidden">
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { backend } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({ backend: vi.fn(), backendJson: vi.fn() }));
|
||||
|
||||
const backendMock = vi.mocked(backend);
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||
|
||||
describe('AI profile provider selection', () => {
|
||||
it('sends only a selected provider and profile settings to backend', async () => {
|
||||
const form = new FormData();
|
||||
form.set('version', 'profile-v2');
|
||||
form.set('provider', 'openai_compatible');
|
||||
form.set('base_url', 'http://127.0.0.1:8317');
|
||||
form.set('model', 'gemini-3.5-flash-lite');
|
||||
form.set('system_prompt', 'Only use provided evidence.');
|
||||
form.set('timeout_seconds', '30');
|
||||
const event = {
|
||||
request: { formData: async () => form },
|
||||
} as unknown as ActionEvent;
|
||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('AI profile action missing');
|
||||
expect(await action(event)).toMatchObject({ success: true });
|
||||
expect(backendMock).toHaveBeenCalledWith(
|
||||
event,
|
||||
'/v1/admin/ai-profiles',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
version: 'profile-v2',
|
||||
provider: 'openai_compatible',
|
||||
base_url: 'http://127.0.0.1:8317',
|
||||
model: 'gemini-3.5-flash-lite',
|
||||
system_prompt: 'Only use provided evidence.',
|
||||
timeout_seconds: 30,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(backendMock.mock.calls[0][2]?.body).not.toContain('api_key');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
FixtureRunnerNotConfigured,
|
||||
submitScoringFixture,
|
||||
} from '$lib/server/fixture-runner';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type RunSummary = components['schemas']['RunSummary'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
fixtureRunnerConfigured: fixtureRunnerConfigured(),
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const adminSession = await backend(event, '/v1/admin/me');
|
||||
if (adminSession.status === 401) {
|
||||
clearAdminSession(event);
|
||||
return fail(401, { message: 'Phiên quản trị đã hết hạn. Hãy đăng nhập lại.' });
|
||||
}
|
||||
if (!adminSession.ok) {
|
||||
return fail(502, { message: 'Không thể xác minh phiên quản trị.' });
|
||||
}
|
||||
if (!fixtureRunnerConfigured()) {
|
||||
return fail(503, {
|
||||
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
||||
});
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await submitScoringFixture(event);
|
||||
} catch (error) {
|
||||
if (error instanceof FixtureRunnerNotConfigured) {
|
||||
return fail(503, {
|
||||
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
||||
});
|
||||
}
|
||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.' });
|
||||
}
|
||||
if (response.status === 401) {
|
||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' });
|
||||
}
|
||||
if (!response.ok) {
|
||||
return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const run = (await response.json()) as RunSummary;
|
||||
return {
|
||||
success: true,
|
||||
run_id: run.run_id,
|
||||
status: run.status,
|
||||
message: 'Đã gửi dữ liệu mẫu.',
|
||||
};
|
||||
} catch {
|
||||
return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Thử quy tắc · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
|
||||
<h1 class="text-3xl font-bold">Thử tính điểm theo quy tắc</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI.
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root class="max-w-2xl">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Dữ liệu mẫu</h2>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid gap-4">
|
||||
{#if !data.fixtureRunnerConfigured}
|
||||
<p class="text-sm text-muted-foreground" role="status">
|
||||
Chưa có mã truy cập cho mẫu này trên server.
|
||||
</p>
|
||||
{/if}
|
||||
{#if form?.message}
|
||||
<p
|
||||
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
{#if form?.success && form.run_id}
|
||||
<p class="text-sm">
|
||||
Trạng thái: {form.status}.
|
||||
<a
|
||||
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
||||
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST">
|
||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured}>Gửi dữ liệu mẫu</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
backend: vi.fn(),
|
||||
clearAdminSession: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/server/fixture-runner', () => ({
|
||||
fixtureRunnerConfigured: vi.fn(),
|
||||
submitScoringFixture: vi.fn(),
|
||||
FixtureRunnerNotConfigured: class extends Error {},
|
||||
}));
|
||||
|
||||
const backendMock = vi.mocked(backend);
|
||||
const configuredMock = vi.mocked(fixtureRunnerConfigured);
|
||||
const submitMock = vi.mocked(submitScoringFixture);
|
||||
const clearSessionMock = vi.mocked(clearAdminSession);
|
||||
const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
|
||||
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||
|
||||
function eventFor(): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||
const setHeaders = vi.fn();
|
||||
return { event: { setHeaders } as unknown as ActionEvent, setHeaders };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configuredMock.mockReturnValue(true);
|
||||
backendMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
});
|
||||
|
||||
describe('fixture runner page', () => {
|
||||
it('shows whether a server-only fixture key is configured', async () => {
|
||||
configuredMock.mockReturnValue(false);
|
||||
const page = await load({} as Parameters<typeof load>[0]);
|
||||
expect(page).toEqual({ fixtureRunnerConfigured: false });
|
||||
});
|
||||
|
||||
it('submits the fixture only after checking the admin session', async () => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
|
||||
);
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event, setHeaders } = eventFor();
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||
expect(submitMock).toHaveBeenCalledWith(event);
|
||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an expired admin session without submitting', async () => {
|
||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event } = eventFor();
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ status: 401 });
|
||||
expect(clearSessionMock).toHaveBeenCalledWith(event);
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unconfigured fixture key without submitting', async () => {
|
||||
configuredMock.mockReturnValue(false);
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor().event);
|
||||
expect(result).toMatchObject({ status: 503 });
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose a rejected tenant key in the response', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor().event);
|
||||
expect(result).toMatchObject({ status: 502 });
|
||||
expect(JSON.stringify(result)).not.toContain('Bearer');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,84 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type TenantKeySummary = components['schemas']['TenantKeySummary'];
|
||||
type TenantKeyList = components['schemas']['TenantKeyList'];
|
||||
type TenantKeySecret = components['schemas']['TenantKeySecret'];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<TenantKeyList>(event, '/v1/admin/tenant-keys');
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.');
|
||||
return { tenants: data.tenants };
|
||||
};
|
||||
|
||||
function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string {
|
||||
if (status === 409 && action === 'create')
|
||||
return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.';
|
||||
if (status === 404) return 'Không tìm thấy tenant key.';
|
||||
if (status === 422) return 'Tenant ID không hợp lệ.';
|
||||
if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.';
|
||||
return 'Không thể cập nhật tenant key. Vui lòng thử lại.';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '').trim();
|
||||
if (!tenant_id || tenant_id.length > 128) {
|
||||
return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' });
|
||||
}
|
||||
const { response, data } = await backendJson<TenantKeySecret>(
|
||||
event,
|
||||
'/v1/admin/tenant-keys',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id }),
|
||||
},
|
||||
);
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'create') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Đã tạo tenant key.',
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
rotate: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const;
|
||||
const { response, data } = await backendJson<TenantKeySecret>(event, path, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'rotate') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Đã xoay vòng key cho ${data.tenant_id}.`,
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
revoke: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const;
|
||||
const { response, data } = await backendJson<TenantKeySummary>(event, path, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'revoke') });
|
||||
}
|
||||
return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` };
|
||||
},
|
||||
};
|
||||
159
apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte
Normal file
159
apps/web/src/routes/(admin)/platform/tenant-keys/+page.svelte
Normal file
@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let copied = $state(false);
|
||||
let copyError = $state(false);
|
||||
|
||||
async function copyKey(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = true;
|
||||
copyError = false;
|
||||
} catch {
|
||||
copied = false;
|
||||
copyError = true;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAction(event: SubmitEvent, action: 'rotate' | 'revoke', tenant: string) {
|
||||
const message =
|
||||
action === 'rotate'
|
||||
? `Thay mã cho ${tenant}? Mã cũ sẽ hết hiệu lực ngay.`
|
||||
: `Thu hồi mã của ${tenant}? Tổ chức này sẽ không gọi được API.`;
|
||||
if (!window.confirm(message)) event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mã truy cập · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
|
||||
<h1 class="text-3xl font-bold">Mã truy cập của tổ chức</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Tạo mã cho tổ chức gửi yêu cầu chấm điểm.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.message}
|
||||
<p
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.tenant_key}
|
||||
<section
|
||||
aria-labelledby="new-key-title"
|
||||
class="mb-6 rounded-lg border border-destructive/40 bg-card p-5"
|
||||
>
|
||||
<h2 id="new-key-title" class="text-base font-semibold">Sao chép mã vừa tạo</h2>
|
||||
<p class="mt-2 text-sm text-destructive">
|
||||
Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<code class="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-sm"
|
||||
>{form.tenant_key}</code
|
||||
>
|
||||
<Button type="button" variant="outline" onclick={() => copyKey(form.tenant_key)}>
|
||||
Sao chép mã
|
||||
</Button>
|
||||
</div>
|
||||
{#if copied}<p class="mt-2 text-sm text-primary" role="status">Đã sao chép mã.</p>{/if}
|
||||
{#if copyError}<p class="mt-2 text-sm text-destructive" role="alert">
|
||||
Chọn và sao chép mã thủ công.
|
||||
</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(18rem,0.75fr)_minmax(0,1.25fr)]">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tạo mã truy cập</h2>
|
||||
<Card.Description>Dành cho tổ chức chưa có mã.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/create" class="grid gap-3">
|
||||
<label for="tenant-id" class="text-sm font-medium">Mã tổ chức</label>
|
||||
<input
|
||||
id="tenant-id"
|
||||
name="tenant_id"
|
||||
required
|
||||
maxlength="128"
|
||||
autocomplete="off"
|
||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button type="submit" class="mt-1 w-fit">Tạo mã</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tổ chức đã có mã</h2>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.tenants.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Chưa có tổ chức nào được cấp mã.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.tenants as tenant (tenant.tenant_id)}
|
||||
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code class="min-w-0 flex-1 break-all text-sm"
|
||||
>{tenant.tenant_id}</code
|
||||
>
|
||||
<Badge variant={tenant.active ? 'secondary' : 'outline'}>
|
||||
{tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
|
||||
tenant.updated_at,
|
||||
).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rotate"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'rotate', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Thay mã
|
||||
</Button>
|
||||
</form>
|
||||
{#if tenant.active}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'revoke', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
Thu hồi
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, backendJson } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
type TenantKeyRequestEvent = Parameters<NonNullable<typeof actions.create>>[0];
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
backend: vi.fn(),
|
||||
backendJson: vi.fn(),
|
||||
}));
|
||||
|
||||
function eventFor(values: Record<string, string>): {
|
||||
event: TenantKeyRequestEvent;
|
||||
setHeaders: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const setHeaders = vi.fn();
|
||||
return {
|
||||
event: {
|
||||
request: new Request('http://localhost/platform/tenant-keys', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(values),
|
||||
}),
|
||||
setHeaders,
|
||||
} as unknown as TenantKeyRequestEvent,
|
||||
setHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
const backendJsonMock = vi.mocked(backendJson);
|
||||
const backendMock = vi.mocked(backend);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('tenant key server actions', () => {
|
||||
it('returns a newly-created key only in the action result', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 201 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' },
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rotates a key through the Admin API and returns the new value once', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' },
|
||||
});
|
||||
const action = actions.rotate;
|
||||
if (!action) throw new Error('rotate action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant-a/rotate',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an existing tenant conflict without returning a key', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: false, status: 409 } as Response,
|
||||
data: null,
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant-a' }).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 409,
|
||||
data: { message: expect.stringContaining('đã có key') },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends revoke only through the backend server helper', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: {
|
||||
tenant_id: 'tenant/a',
|
||||
active: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
},
|
||||
});
|
||||
const action = actions.revoke;
|
||||
if (!action) throw new Error('revoke action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant/a' }).event);
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant%2Fa',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
expect(backendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user