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:
+12
-6
@@ -1,15 +1,19 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { lazy, Suspense } from "react"
|
||||||
import { Routes, Route, Navigate } from "react-router-dom"
|
import { Routes, Route, Navigate } from "react-router-dom"
|
||||||
import { ProtectedRoute } from "@/auth/ProtectedRoute"
|
import { ProtectedRoute } from "@/auth/ProtectedRoute"
|
||||||
import { Layout } from "@/components/Layout"
|
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 { LoginPage } from "@/pages/LoginPage"
|
||||||
import { RegisterPage } from "@/pages/RegisterPage"
|
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
|
// 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.
|
// each <Route> below a one-liner instead of repeating both on every page.
|
||||||
@@ -23,6 +27,7 @@ function Protected({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={<div className="p-6 text-muted-foreground">Загрузка…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
@@ -34,5 +39,6 @@ export function App() {
|
|||||||
<Route path="/schedule" element={<Protected><SchedulePage /></Protected>} />
|
<Route path="/schedule" element={<Protected><SchedulePage /></Protected>} />
|
||||||
<Route path="/channels" element={<Protected><ChannelsPage /></Protected>} />
|
<Route path="/channels" element={<Protected><ChannelsPage /></Protected>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ test("отрисовывает список каналов без секрета
|
|||||||
expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument()
|
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 () => {
|
test("создание telegram-канала собирает config.chat_id + secret=bot_token", async () => {
|
||||||
const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({
|
const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({
|
||||||
id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true,
|
id: "c3", type: "telegram", config: { chat_id: "999" }, enabled: true,
|
||||||
|
|||||||
@@ -69,13 +69,15 @@ type ChannelForm = z.infer<typeof channelFormSchema>
|
|||||||
|
|
||||||
const EMPTY_FORM: ChannelForm = { type: "telegram", chatId: "", botToken: "", url: "" }
|
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
|
// тип канала с чувствительным полем в config не «протёк» в DOM автоматически
|
||||||
// rendering every key is always safe to show in the list.
|
// (Object.entries по всему config печатал бы любое поле, включая случайно
|
||||||
function formatConfig(config: object): string {
|
// сохранённый секрет).
|
||||||
const entries = Object.entries(config as Record<string, unknown>)
|
function formatConfig(type: string, config: object): string {
|
||||||
if (entries.length === 0) return "—"
|
const c = config as Record<string, unknown>
|
||||||
return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ")
|
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 }) {
|
function ChannelForm({ onCreated }: { onCreated: () => void }) {
|
||||||
@@ -294,7 +296,7 @@ export function ChannelsPage() {
|
|||||||
<TableRow key={c.id}>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="font-dns">{c.type}</TableCell>
|
<TableCell className="font-dns">{c.type}</TableCell>
|
||||||
<TableCell className="font-dns text-xs text-muted-foreground">
|
<TableCell className="font-dns text-xs text-muted-foreground">
|
||||||
{formatConfig(c.config)}
|
{formatConfig(c.type, c.config)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span
|
<span
|
||||||
|
|||||||
Reference in New Issue
Block a user