perf(web): route-level code-splitting; harden channel config rendering

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 16:04:17 +07:00
parent 41844d49a0
commit 8c35aed8f2
3 changed files with 43 additions and 25 deletions
+23 -17
View File
@@ -1,15 +1,19 @@
import type { ReactNode } from "react"
import { lazy, Suspense } from "react"
import { Routes, Route, Navigate } from "react-router-dom"
import { ProtectedRoute } from "@/auth/ProtectedRoute"
import { Layout } from "@/components/Layout"
import { AccountsPage } from "@/pages/AccountsPage"
import { ChannelsPage } from "@/pages/ChannelsPage"
import { DomainDiffPage } from "@/pages/DomainDiffPage"
import { DomainsPage } from "@/pages/DomainsPage"
import { LoginPage } from "@/pages/LoginPage"
import { RegisterPage } from "@/pages/RegisterPage"
import { SchedulePage } from "@/pages/SchedulePage"
import { TemplatesPage } from "@/pages/TemplatesPage"
// Маршрутные страницы грузятся лениво — Vite бьёт бандл на per-route чанки,
// первый экран (login/register) остаётся статичным, т.к. нужен сразу.
const DomainsPage = lazy(() => import("@/pages/DomainsPage").then((m) => ({ default: m.DomainsPage })))
const DomainDiffPage = lazy(() => import("@/pages/DomainDiffPage").then((m) => ({ default: m.DomainDiffPage })))
const AccountsPage = lazy(() => import("@/pages/AccountsPage").then((m) => ({ default: m.AccountsPage })))
const TemplatesPage = lazy(() => import("@/pages/TemplatesPage").then((m) => ({ default: m.TemplatesPage })))
const SchedulePage = lazy(() => import("@/pages/SchedulePage").then((m) => ({ default: m.SchedulePage })))
const ChannelsPage = lazy(() => import("@/pages/ChannelsPage").then((m) => ({ default: m.ChannelsPage })))
// Every non-auth route shares the same guard + chrome; wrapping here keeps
// each <Route> below a one-liner instead of repeating both on every page.
@@ -23,16 +27,18 @@ function Protected({ children }: { children: ReactNode }) {
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<Navigate to="/domains" replace />} />
<Route path="/domains" element={<Protected><DomainsPage /></Protected>} />
<Route path="/domains/:id" element={<Protected><DomainDiffPage /></Protected>} />
<Route path="/accounts" element={<Protected><AccountsPage /></Protected>} />
<Route path="/templates" element={<Protected><TemplatesPage /></Protected>} />
<Route path="/schedule" element={<Protected><SchedulePage /></Protected>} />
<Route path="/channels" element={<Protected><ChannelsPage /></Protected>} />
</Routes>
<Suspense fallback={<div className="p-6 text-muted-foreground">Загрузка</div>}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<Navigate to="/domains" replace />} />
<Route path="/domains" element={<Protected><DomainsPage /></Protected>} />
<Route path="/domains/:id" element={<Protected><DomainDiffPage /></Protected>} />
<Route path="/accounts" element={<Protected><AccountsPage /></Protected>} />
<Route path="/templates" element={<Protected><TemplatesPage /></Protected>} />
<Route path="/schedule" element={<Protected><SchedulePage /></Protected>} />
<Route path="/channels" element={<Protected><ChannelsPage /></Protected>} />
</Routes>
</Suspense>
)
}
+10
View File
@@ -48,6 +48,16 @@ test("отрисовывает список каналов без секрета
expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument()
})
test("formatConfig — вайтлист по типу канала не печатает незнакомые поля config", async () => {
vi.spyOn(api, "listChannels").mockResolvedValue([
{ id: "c5", type: "webhook", config: { url: "https://x", token: "LEAK" }, enabled: true },
])
renderPage()
expect(await screen.findByText(/url: https:\/\/x/)).toBeInTheDocument()
expect(document.body.textContent).not.toMatch(/LEAK/)
})
test("создание telegram-канала собирает config.chat_id + secret=bot_token", async () => {
const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({
id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true,
+10 -8
View File
@@ -69,13 +69,15 @@ type ChannelForm = z.infer<typeof channelFormSchema>
const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", url: "" }
// Channel.config is a generic `object` end-to-end (T5/T7) — it never carries
// the secret (bot_token/signing key), only the public half (chat_id/url), so
// rendering every key is always safe to show in the list.
function formatConfig(config: object): string {
const entries = Object.entries(config as Record<string, unknown>)
if (entries.length === 0) return "—"
return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ")
// Печатаем ТОЛЬКО известные несекретные поля по типу канала — чтобы новый
// тип канала с чувствительным полем в config не «протёк» в DOM автоматически
// (Object.entries по всему config печатал бы любое поле, включая случайно
// сохранённый секрет).
function formatConfig(type: string, config: object): string {
const c = config as Record<string, unknown>
if (type === "telegram") return c.chat_id ? `chat_id: ${String(c.chat_id)}` : ""
if (type === "webhook") return c.url ? `url: ${String(c.url)}` : ""
return ""
}
function ChannelForm({ onCreated }: { onCreated: () => void }) {
@@ -294,7 +296,7 @@ export function ChannelsPage() {
<TableRow key={c.id}>
<TableCell className="font-dns">{c.type}</TableCell>
<TableCell className="font-dns text-xs text-muted-foreground">
{formatConfig(c.config)}
{formatConfig(c.type, c.config)}
</TableCell>
<TableCell>
<span