From 34422420cac4ec53d77173dd48a69368315818cb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:40:29 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8B=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9,=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA,=20dr?= =?UTF-8?q?ift-badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 4 + web/src/components/DomainHistory.test.tsx | 47 +++ web/src/components/DomainHistory.tsx | 66 +++++ web/src/components/Layout.tsx | 4 +- web/src/components/StatusBadge.test.tsx | 39 +++ web/src/components/StatusBadge.tsx | 42 +++ web/src/pages/ChannelsPage.test.tsx | 146 +++++++++ web/src/pages/ChannelsPage.tsx | 342 ++++++++++++++++++++++ web/src/pages/DomainDiffPage.test.tsx | 1 + web/src/pages/DomainDiffPage.tsx | 3 + web/src/pages/DomainsPage.test.tsx | 13 +- web/src/pages/DomainsPage.tsx | 5 + web/src/pages/SchedulePage.test.tsx | 83 ++++++ web/src/pages/SchedulePage.tsx | 145 +++++++++ 14 files changed, 937 insertions(+), 3 deletions(-) create mode 100644 web/src/components/DomainHistory.test.tsx create mode 100644 web/src/components/DomainHistory.tsx create mode 100644 web/src/components/StatusBadge.test.tsx create mode 100644 web/src/components/StatusBadge.tsx create mode 100644 web/src/pages/ChannelsPage.test.tsx create mode 100644 web/src/pages/ChannelsPage.tsx create mode 100644 web/src/pages/SchedulePage.test.tsx create mode 100644 web/src/pages/SchedulePage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f2fd46..8f81bb1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,10 +3,12 @@ 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" // Every non-auth route shares the same guard + chrome; wrapping here keeps @@ -29,6 +31,8 @@ export function App() { } /> } /> } /> + } /> + } /> ) } diff --git a/web/src/components/DomainHistory.test.tsx b/web/src/components/DomainHistory.test.tsx new file mode 100644 index 0000000..50f8f3b --- /dev/null +++ b/web/src/components/DomainHistory.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { AuthProvider } from "@/auth/AuthContext" +import { api } from "@/api/client" +import { vi, beforeEach, test, expect } from "vitest" +import { DomainHistory } from "./DomainHistory" + +const PROJECT_ID = "p1" + +function renderComponent() { + const qc = new QueryClient() + return render( + + + + + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(api.auth, "me").mockResolvedValue({ + user: { id: "u1", email: "a@b.com" }, + project: { id: PROJECT_ID, name: "Default" }, + }) +}) + +test("отрисовывает список проверок со сводкой updates/prunes", async () => { + vi.spyOn(api, "domainHistory").mockResolvedValue([ + { id: "r1", createdAt: "2026-07-01T10:00:00Z", result: { updates: 2, prunes: 1 } }, + { id: "r2", createdAt: "2026-06-30T10:00:00Z", result: { updates: 0, prunes: 0 } }, + ]) + + renderComponent() + + expect(await screen.findByText(/updates:\s*2/i)).toBeInTheDocument() + expect(screen.getByText(/prunes:\s*1/i)).toBeInTheDocument() +}) + +test("пустое состояние при отсутствии истории", async () => { + vi.spyOn(api, "domainHistory").mockResolvedValue([]) + + renderComponent() + + expect(await screen.findByText(/проверок пока нет/i)).toBeInTheDocument() +}) diff --git a/web/src/components/DomainHistory.tsx b/web/src/components/DomainHistory.tsx new file mode 100644 index 0000000..e1e40f7 --- /dev/null +++ b/web/src/components/DomainHistory.tsx @@ -0,0 +1,66 @@ +import { History, Loader2 } from "lucide-react" +import { useDomainHistory } from "@/hooks/useApi" + +// check_runs.result is a provider-neutral JSON summary written by +// store.SaveCheckRun (Фаза 3, T1/T4): {"updates": N, "prunes": N}. We read it +// defensively since it's typed as `object` end-to-end and older/foreign rows +// could in principle omit a key. +function summarize(result: object): string { + const r = result as Record + const updates = typeof r.updates === "number" ? r.updates : 0 + const prunes = typeof r.prunes === "number" ? r.prunes : 0 + return `updates: ${updates} · prunes: ${prunes}` +} + +function formatTimestamp(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +} + +export function DomainHistory({ domainId }: { domainId: string }) { + const history = useDomainHistory(domainId) + const runs = history.data ?? [] + + return ( +
+
+ +

+ История проверок +

+
+ + {history.isPending ? ( +
+ + Загружаю историю… +
+ ) : runs.length === 0 ? ( +

+ Проверок пока нет — история появится после первого запуска планировщика. +

+ ) : ( +
+ {runs.map((run, i) => ( +
+ + {formatTimestamp(run.createdAt)} + + {summarize(run.result)} +
+ ))} +
+ )} +
+ ) +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index f0c6ffc..ba4d200 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { NavLink, useLocation, useNavigate } from "react-router-dom" -import { Globe, LogOut, Users, LayoutTemplate, SquareTerminal } from "lucide-react" +import { BellRing, CalendarClock, Globe, LogOut, Users, LayoutTemplate, SquareTerminal } from "lucide-react" import { useAuth } from "@/auth/AuthContext" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" @@ -9,6 +9,8 @@ const NAV = [ { to: "/domains", label: "Domains", icon: Globe }, { to: "/accounts", label: "Accounts", icon: Users }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, + { to: "/schedule", label: "Schedule", icon: CalendarClock }, + { to: "/channels", label: "Channels", icon: BellRing }, ] as const export function Layout({ children }: { children: ReactNode }) { diff --git a/web/src/components/StatusBadge.test.tsx b/web/src/components/StatusBadge.test.tsx new file mode 100644 index 0000000..194bb63 --- /dev/null +++ b/web/src/components/StatusBadge.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react" +import { test, expect } from "vitest" +import { StatusBadge } from "./StatusBadge" + +test("in_sync — emerald, текст «in sync»", () => { + render() + expect(screen.getByText("in sync")).toBeInTheDocument() + const badge = screen.getByText("in sync").closest('[data-slot="status-badge"]') + expect(badge).toHaveAttribute("data-status", "in_sync") + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-add)" }) +}) + +test("drift — amber, текст «drift»", () => { + render() + expect(screen.getByText("drift")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-update)" }) +}) + +test("error — rose, текст «error»", () => { + render() + expect(screen.getByText("error")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-delete)" }) +}) + +test("unknown — muted, текст «unknown»", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() + expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" }) +}) + +test("отсутствие статуса трактуется как unknown", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() +}) + +test("неизвестное значение статуса не падает и рендерит unknown", () => { + render() + expect(screen.getByText("unknown")).toBeInTheDocument() +}) diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..6d3711c --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -0,0 +1,42 @@ +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +// Mirrors backend check status (store.Domain.LastCheckStatus / T4-T5): +// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already +// established for the domain-diff console so a drifted zone reads the same +// "amber" whether you're looking at the list or the diff view. +export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" + +const STATUS_META: Record = { + in_sync: { label: "in sync", color: "var(--diff-add)" }, + drift: { label: "drift", color: "var(--diff-update)" }, + error: { label: "error", color: "var(--diff-delete)" }, + unknown: { label: "unknown", color: "var(--diff-readonly)" }, +} + +function resolveStatus(status?: string): CheckStatus { + if (status === "in_sync" || status === "drift" || status === "error") return status + return "unknown" +} + +export function StatusBadge({ status, className }: { status?: string; className?: string }) { + const resolved = resolveStatus(status) + const meta = STATUS_META[resolved] + + return ( + + + {meta.label} + + ) +} diff --git a/web/src/pages/ChannelsPage.test.tsx b/web/src/pages/ChannelsPage.test.tsx new file mode 100644 index 0000000..162041f --- /dev/null +++ b/web/src/pages/ChannelsPage.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MemoryRouter } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ChannelsPage } from "./ChannelsPage" +import { AuthProvider } from "@/auth/AuthContext" +import { api } from "@/api/client" +import { vi, beforeEach, test, expect } from "vitest" +import type { Channel } from "@/api/types" + +const PROJECT_ID = "p1" +const channels: Channel[] = [ + { id: "c1", type: "telegram", config: { chat_id: "123456" }, enabled: true }, + { id: "c2", type: "webhook", config: { url: "https://hooks.example.com/x" }, enabled: false }, +] + +function renderPage() { + const qc = new QueryClient() + return render( + + + + + + + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(api.auth, "me").mockResolvedValue({ + user: { id: "u1", email: "a@b.com" }, + project: { id: PROJECT_ID, name: "Default" }, + }) + vi.spyOn(api, "listChannels").mockResolvedValue(channels) +}) + +test("отрисовывает список каналов без секрета", async () => { + renderPage() + + expect(await screen.findByText("telegram")).toBeInTheDocument() + expect(screen.getByText("webhook")).toBeInTheDocument() + expect(screen.getByText(/123456/)).toBeInTheDocument() + expect(screen.getByText(/hooks\.example\.com/)).toBeInTheDocument() + + expect(document.body.textContent).not.toMatch(/bot_token/i) + expect(screen.queryByDisplayValue(/123456/)).not.toBeInTheDocument() +}) + +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, + }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("combobox", { name: /тип канала/i })) + await user.click(await screen.findByRole("option", { name: /telegram/i })) + + await user.type(screen.getByLabelText(/chat id/i), "999") + await user.type(screen.getByLabelText(/bot token/i), "SECRET_TOKEN") + await user.click(screen.getByRole("button", { name: /добавить канал/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { + type: "telegram", + config: { chat_id: "999" }, + secret: "SECRET_TOKEN", + }), + ) + + expect(document.body.textContent).not.toMatch(/SECRET_TOKEN/) +}) + +test("создание webhook-канала собирает config.url без секрета", async () => { + const createSpy = vi.spyOn(api, "createChannel").mockResolvedValue({ + id: "c4", type: "webhook", config: { url: "https://hooks.example.com/y" }, enabled: true, + }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("combobox", { name: /тип канала/i })) + await user.click(await screen.findByRole("option", { name: /webhook/i })) + + await user.type(screen.getByLabelText(/url/i), "https://hooks.example.com/y") + await user.click(screen.getByRole("button", { name: /добавить канал/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { + type: "webhook", + config: { url: "https://hooks.example.com/y" }, + secret: "", + }), + ) +}) + +test("удаление канала вызывает api.deleteChannel", async () => { + const deleteSpy = vi.spyOn(api, "deleteChannel").mockResolvedValue(undefined) + vi.spyOn(window, "confirm").mockReturnValue(true) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + await user.click(screen.getByRole("button", { name: /удалить канал telegram/i })) + + await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith(PROJECT_ID, "c1")) +}) + +test("кнопка «Тест» вызывает api.testChannel", async () => { + const testSpy = vi.spyOn(api, "testChannel").mockResolvedValue({ status: "ok" }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + const testButtons = screen.getAllByRole("button", { name: /тест/i }) + await user.click(testButtons[0]) + + await waitFor(() => expect(testSpy).toHaveBeenCalledWith(PROJECT_ID, "c1")) +}) + +test("ошибка тест-отправки отображается как alert", async () => { + vi.spyOn(api, "testChannel").mockRejectedValue(new Error("Канал не отвечает")) + const user = userEvent.setup() + renderPage() + + await screen.findByText("telegram") + + const testButtons = screen.getAllByRole("button", { name: /тест/i }) + await user.click(testButtons[0]) + + expect(await screen.findByRole("alert")).toHaveTextContent("Канал не отвечает") +}) + +test("пустое состояние при отсутствии каналов", async () => { + vi.spyOn(api, "listChannels").mockResolvedValue([]) + renderPage() + + expect(await screen.findByText(/каналов пока нет/i)).toBeInTheDocument() +}) diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx new file mode 100644 index 0000000..ad51923 --- /dev/null +++ b/web/src/pages/ChannelsPage.tsx @@ -0,0 +1,342 @@ +import { useId, useState } from "react" +import { Controller, useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Inbox, Loader2, Plus, Send, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Field, + FieldContent, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@/components/ui/field" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { useChannels, useCreateChannel, useDeleteChannel, useTestChannel } from "@/hooks/useApi" +import { cn } from "@/lib/utils" +import type { Channel, CreateChannelInput } from "@/api/types" + +const CHANNEL_TYPES = [ + { value: "telegram", label: "Telegram" }, + { value: "webhook", label: "Webhook" }, +] as const + +const channelFormSchema = z + .object({ + type: z.enum(["telegram", "webhook"]), + chatId: z.string(), + botToken: z.string(), + url: z.string(), + }) + .superRefine((values, ctx) => { + if (values.type === "telegram") { + if (!values.chatId.trim()) { + ctx.addIssue({ code: "custom", path: ["chatId"], message: "Укажите chat_id" }) + } + if (!values.botToken.trim()) { + ctx.addIssue({ code: "custom", path: ["botToken"], message: "Укажите bot token" }) + } + return + } + if (!values.url.trim()) { + ctx.addIssue({ code: "custom", path: ["url"], message: "Укажите URL" }) + return + } + try { + new URL(values.url) + } catch { + ctx.addIssue({ code: "custom", path: ["url"], message: "Некорректный URL, включая протокол http(s)://" }) + } + }) + +type ChannelForm = z.infer + +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) + if (entries.length === 0) return "—" + return entries.map(([k, v]) => `${k}=${String(v)}`).join(" · ") +} + +function ChannelForm({ onCreated }: { onCreated: () => void }) { + const createChannel = useCreateChannel() + const typeFieldId = useId() + const chatIdFieldId = useId() + const botTokenFieldId = useId() + const urlFieldId = useId() + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(channelFormSchema), + defaultValues: EMPTY_FORM, + }) + + const type = useWatch({ control, name: "type" }) + + function onSubmit(values: ChannelForm) { + const input: CreateChannelInput = + values.type === "telegram" + ? { type: "telegram", config: { chat_id: values.chatId.trim() }, secret: values.botToken.trim() } + : { type: "webhook", config: { url: values.url.trim() }, secret: "" } + + createChannel.mutate(input, { + onSuccess: () => { + reset(EMPTY_FORM) + onCreated() + }, + }) + } + + return ( +
+
+ + + Тип канала + + ( + + )} + /> + + + + {type === "telegram" ? ( + <> + + Chat ID + + ( + + )} + /> + + + + + Bot token + + ( + + )} + /> + + + + + ) : ( + + URL + + ( + + )} + /> + + + + )} + +
+ +
+ {createChannel.isError && ( + + {createChannel.error.message} + + )} + +
+
+ ) +} + +export function ChannelsPage() { + const channels = useChannels() + const deleteChannel = useDeleteChannel() + const testChannel = useTestChannel() + const [testingId, setTestingId] = useState(null) + + const channelList = channels.data ?? [] + + function onDelete(channel: Channel) { + if (window.confirm(`Удалить канал «${channel.type}»? Действие необратимо.`)) { + deleteChannel.mutate(channel.id) + } + } + + function onTest(channel: Channel) { + setTestingId(channel.id) + testChannel.mutate(channel.id) + } + + return ( +
+
+ + notifications + +

Каналы уведомлений

+
+ + setTestingId(null)} /> + + {deleteChannel.isError && ( + + {deleteChannel.error.message} + + )} + {testChannel.isError && ( + + {testChannel.error.message} + + )} + + {channelList.length === 0 ? ( +
+ + Каналов пока нет — добавьте Telegram или Webhook выше. +
+ ) : ( + + + + Тип + Конфигурация + Статус + Действия + + + + {channelList.map((c) => ( + + {c.type} + + {formatConfig(c.config)} + + + + {c.enabled ? "включён" : "выключен"} + + + +
+ + +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 4c64314..b66adc6 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -28,6 +28,7 @@ beforeEach(() => { user: { id: "u1", email: "a@b.com" }, project: { id: PROJECT_ID, name: "Default" }, }) + vi.spyOn(api, "domainHistory").mockResolvedValue([]) }) test("apply sends applyPrunes=false by default, true only after opting in", async () => { diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index 7749a99..c93ab41 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -2,6 +2,7 @@ import { useId, useState } from "react" import { useParams } from "react-router-dom" import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react" import { DiffView } from "@/components/DiffView" +import { DomainHistory } from "@/components/DomainHistory" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" @@ -137,6 +138,8 @@ export function DomainDiffPage() { )} + + ) } diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx index 3822faf..d4e93ba 100644 --- a/web/src/pages/DomainsPage.test.tsx +++ b/web/src/pages/DomainsPage.test.tsx @@ -18,8 +18,8 @@ const templates: Template[] = [ { id: "t2", name: "Minimal", records: [], version: 1 }, ] const domains: Domain[] = [ - { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null }, - { id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1" }, + { id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null, lastCheckStatus: "drift" }, + { id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1", lastCheckStatus: "in_sync" }, ] function renderPage() { @@ -108,3 +108,12 @@ test("пустое состояние при отсутствии доменов expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument() }) + +test("drift-badge отражает lastCheckStatus каждого домена", async () => { + renderPage() + + await screen.findByText("example.com.") + + expect(screen.getByText("drift")).toBeInTheDocument() + expect(screen.getByText("in sync")).toBeInTheDocument() +}) diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx index 580f23e..ad4afa2 100644 --- a/web/src/pages/DomainsPage.tsx +++ b/web/src/pages/DomainsPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { Link } from "react-router-dom" import { Inbox, Loader2, Trash2, Upload } from "lucide-react" import { Button } from "@/components/ui/button" +import { StatusBadge } from "@/components/StatusBadge" import { Select, SelectContent, @@ -134,6 +135,7 @@ export function DomainsPage() { Zone Учётка Шаблон + Статус Действия @@ -167,6 +169,9 @@ export function DomainsPage() { + + +
+
+ + ) +} + +export function SchedulePage() { + const schedule = useSchedule() + + return ( +
+
+ + scheduler + +

Расписание проверок

+
+ + {schedule.isPending ? ( +
+ + Загружаю расписание… +
+ ) : ( + + )} +
+ ) +}