Песочница отчётов для ИИ: recharts в custom pages + внешние БД с read-only шлюзом

- JsComponentRenderer: ctx.libs.charts (recharts) — графики в кастомных страницах
  и JS-вкладках без redeploy; справка get_js_coding_reference обновлена
- Внешние БД (миграция 0088): вкладка «Настройки → Внешние БД» — реестр подключений
  (пароль AES-256-GCM, env EXTERNAL_DB_SECRET, по API не отдаётся), тест подключения,
  allowlist таблиц на подключение
- Read-only шлюз POST /api/external-db/:id/query (finance.manage): только SELECT,
  без мульти-выражений, обёртка с LIMIT 5000, statement_timeout 10с, проверка таблиц
  по allowlist, аудит в external_db_query_log (кто/SQL/строки/мс)
- Назначение: ИИ-агенты строят отчёты и виджеты поверх витрин (напр. НДС 1С) без
  новых серверных эндпоинтов и пересборки
This commit is contained in:
2026-09-29 22:50:40 +03:00
parent 9827b5df33
commit 59bd802bf7
7 changed files with 686 additions and 1 deletions

View File

@@ -0,0 +1,292 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil, Trash2, PlugZap, Database } from "lucide-react";
interface Conn {
id: number;
name: string;
host: string;
port: number;
database: string;
username: string;
ssl: boolean;
allowedTables: string[];
isActive: boolean;
}
interface FormState {
name: string;
host: string;
port: string;
database: string;
username: string;
password: string;
ssl: boolean;
allowedTables: string;
}
const EMPTY_FORM: FormState = {
name: "", host: "", port: "5432", database: "", username: "", password: "", ssl: false, allowedTables: "",
};
export function ExternalDbSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Conn | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [testing, setTesting] = useState<number | null>(null);
const listQuery = useQuery<Conn[]>({
queryKey: ["/api/external-db/connections"],
queryFn: async () => {
const r = await fetch("/api/external-db/connections");
if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`);
return r.json();
},
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/external-db/connections"] });
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
name: form.name,
host: form.host,
port: Number(form.port) || 5432,
database: form.database,
username: form.username,
ssl: form.ssl,
allowedTables: form.allowedTables.split(/[\s,]+/).map(s => s.trim().toLowerCase()).filter(Boolean),
...(form.password ? { password: form.password } : {}),
};
if (editing) {
await apiRequest("PATCH", `/api/external-db/connections/${editing.id}`, payload);
} else {
await apiRequest("POST", "/api/external-db/connections", { ...payload, password: form.password });
}
},
onSuccess: () => {
toast({ title: editing ? "Подключение обновлено" : "Подключение создано" });
setDialogOpen(false);
invalidate();
},
onError: (e: any) => toast({ title: e?.message || "Ошибка сохранения", variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => apiRequest("DELETE", `/api/external-db/connections/${id}`),
onSuccess: () => { toast({ title: "Подключение удалено" }); invalidate(); },
onError: (e: any) => toast({ title: e?.message || "Ошибка удаления", variant: "destructive" }),
});
const toggleMutation = useMutation({
mutationFn: async (c: Conn) => apiRequest("PATCH", `/api/external-db/connections/${c.id}`, { isActive: !c.isActive }),
onSuccess: () => invalidate(),
onError: (e: any) => toast({ title: e?.message || "Ошибка", variant: "destructive" }),
});
async function testConnection(id: number) {
setTesting(id);
try {
const r = await fetch(`/api/external-db/connections/${id}/test`, { method: "POST" });
const res = await r.json();
if (res.ok) toast({ title: `Подключение успешно (${res.durationMs} мс)` });
else toast({ title: res.error || "Ошибка подключения", variant: "destructive" });
} catch (e: any) {
toast({ title: e?.message || "Ошибка подключения", variant: "destructive" });
} finally {
setTesting(null);
}
}
function openEdit(c: Conn) {
setEditing(c);
setForm({
name: c.name, host: c.host, port: String(c.port), database: c.database,
username: c.username, password: "", ssl: c.ssl,
allowedTables: (c.allowedTables ?? []).join(", "),
});
setDialogOpen(true);
}
function openCreate() {
setEditing(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
}
const set = (k: keyof FormState) => (e: any) => setForm(f => ({ ...f, [k]: e.target ? e.target.value : e }));
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Database className="w-4 h-4" />
Внешние базы данных
</CardTitle>
<CardDescription className="text-xs mt-1">
Read-only подключения для отчётов и виджетов. Запросы через шлюз: только SELECT,
только таблицы из списка, лимит 5000 строк. Каждый запрос пишется в журнал.
</CardDescription>
</div>
<Button size="sm" onClick={openCreate} className="gap-1">
<Plus className="w-3.5 h-3.5" />
Добавить
</Button>
</div>
</CardHeader>
<CardContent>
{(listQuery.data ?? []).length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Сервер / БД</TableHead>
<TableHead>Пользователь</TableHead>
<TableHead>Разрешённые таблицы</TableHead>
<TableHead className="w-20">Активно</TableHead>
<TableHead className="w-32 text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data ?? []).map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{c.host}:{c.port}/{c.database}{c.ssl ? " · SSL" : ""}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{c.username}</TableCell>
<TableCell className="text-xs max-w-[280px]">
{(c.allowedTables ?? []).length > 0 ? (
<span className="line-clamp-2" title={c.allowedTables.join(", ")}>
{c.allowedTables.join(", ")}
</span>
) : (
<Badge variant="destructive" className="text-[10px]">шлюз закрыт</Badge>
)}
</TableCell>
<TableCell>
<Switch
checked={c.isActive}
onCheckedChange={() => toggleMutation.mutate(c)}
disabled={toggleMutation.isPending}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => testConnection(c.id)}
disabled={testing === c.id}
title="Проверить подключение"
>
<PlugZap className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(c)} title="Изменить">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
onClick={() => { if (confirm(`Удалить подключение «${c.name}»?`)) deleteMutation.mutate(c.id); }}
title="Удалить"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">
Подключений нет. Добавьте read-only доступ к внешней БД для отчётов и виджетов.
</p>
)}
<p className="text-xs text-muted-foreground mt-4">
Запросы из отчётов: POST /api/external-db/{"{id}"}/query {"{ sql }"} — доступен пользователям
с правом «Финансы». Рекомендуется заводить во внешней БД пользователя с GRANT SELECT только
на нужные таблицы (шлюз — второй рубеж, не замена прав БД).
</p>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Изменить подключение" : "Новое подключение"}</DialogTitle>
<DialogDescription className="text-xs">
Пароль хранится в зашифрованном виде и по API не отдаётся.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label className="text-xs">Название</Label>
<Input value={form.name} onChange={set("name")} placeholder="Финансовая БД (омегапром)" />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-1.5 col-span-2">
<Label className="text-xs">Хост</Label>
<Input value={form.host} onChange={set("host")} placeholder="db.example.com" />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Порт</Label>
<Input value={form.port} onChange={set("port")} inputMode="numeric" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">База данных</Label>
<Input value={form.database} onChange={set("database")} placeholder="omegaprom" />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Пользователь</Label>
<Input value={form.username} onChange={set("username")} placeholder="read_only_user" />
</div>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Пароль {editing ? "(оставьте пустым, чтобы не менять)" : ""}</Label>
<Input type="password" value={form.password} onChange={set("password")} autoComplete="new-password" />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Разрешённые таблицы (через запятую)</Label>
<Input
value={form.allowedTables}
onChange={set("allowedTables")}
placeholder="nds_monthly, nds_input_balance, nds_sync_log, nds_contract_marker, nds_advance_vat_tracker"
/>
<p className="text-[11px] text-muted-foreground">
Только эти таблицы доступны через read-only шлюз. Пусто = шлюз закрыт.
</p>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.ssl} onCheckedChange={(v) => setForm(f => ({ ...f, ssl: v }))} />
<Label className="text-xs">SSL (rejectUnauthorized=false)</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !form.name || !form.host || !form.database || !form.username || (!editing && !form.password)}
>
{saveMutation.isPending ? "Сохранение..." : "Сохранить"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,6 @@
import { Component, useState, useEffect, ComponentType, ReactNode } from 'react';
import * as ReactLib from 'react';
import * as ChartsLib from 'recharts';
import type { TransformResult } from '@babel/standalone';
import { useLocation } from 'wouter';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
@@ -104,6 +105,8 @@ interface CtxObject {
libs: {
React: typeof ReactLib;
ui: CtxUiLib;
// Графики для отчётов/виджетов (recharts: BarChart, Line, ComposedChart, ReferenceArea, ...)
charts: typeof ChartsLib;
SafeHtml: typeof SafeHtmlRenderer;
useEvents: typeof useEvents;
// Чат задачи — чтобы кастомные layout'ы могли встроить его (пропсы: { taskId: number })
@@ -270,7 +273,7 @@ function buildCtx(
};
return {
libs: { React: ReactLib, ui, SafeHtml: SafeHtmlRenderer, useEvents, TaskChat },
libs: { React: ReactLib, ui, charts: ChartsLib, SafeHtml: SafeHtmlRenderer, useEvents, TaskChat },
request: async ({ url, method = 'GET', data }: CtxRequestOptions): Promise<unknown> => {
const res = await apiRequest(method.toUpperCase(), url, data);
return res.json();

View File

@@ -20,6 +20,7 @@ import { Link } from 'wouter';
import { BotsContent } from '@/pages/Bots';
import { AutomationsContent } from '@/pages/Automations';
import { AppearanceSettings } from '@/pages/AppearanceSettings';
import { ExternalDbSettings } from '@/components/settings/ExternalDbSettings';
import { ErrorLogsTab } from '@/components/settings/ErrorLogsTab';
import { Badge } from '@/components/ui/badge';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@@ -1768,6 +1769,10 @@ const Settings = () => {
<BookOpen className="w-3 h-3" />
API Docs
</TabsTrigger>
<TabsTrigger value="external-db" className="h-6 px-2.5 text-[11px] gap-1">
<Database className="w-3 h-3" />
Внешние БД
</TabsTrigger>
<TabsTrigger value="error-logs" className="h-6 px-2.5 text-[11px] gap-1">
<ScrollText className="w-3 h-3" />
Журнал ошибок
@@ -2008,6 +2013,10 @@ const Settings = () => {
</div>
</TabsContent>
<TabsContent value="external-db" className="space-y-4">
<ExternalDbSettings />
</TabsContent>
<TabsContent value="error-logs" className="space-y-4">
<div className="border border-border rounded">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">