Песочница отчётов для ИИ: 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:
292
client/src/components/settings/ExternalDbSettings.tsx
Normal file
292
client/src/components/settings/ExternalDbSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user