merge: Фаза 1C — React SPA

- web/: Vite+React+TS+TanStack Query+shadcn/Tailwind, тёмная technical console
- API-клиент+типы+хуки (зеркало Go DTO), DEFAULT_PROJECT_ID
- DiffView+DomainDiffPage (prune-guard по умолчанию выключен)
- DomainsPage (импорт зон, привязка шаблона), AccountsPage (secret+Selectel), TemplatesPage+RecordEditor
- internal/web: embed SPA + SPA-fallback, cmd/server монтирование (API приоритетно)
Финальный ревью: READY TO MERGE. web 32 теста, Go 74/14 пакетов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:17:45 +07:00
56 changed files with 10664 additions and 1 deletions
+12
View File
@@ -3,3 +3,15 @@
/bin/ /bin/
*.env *.env
.env .env
# web (Vite frontend)
web/node_modules/
web/dist/
# internal/web/dist: real build output is generated by `make web` and
# gitignored, EXCEPT index.html — a minimal placeholder is committed so
# `go build ./...` (which //go:embed all:dist needs) works on a clean
# clone without npm/CI web-build step. `make web` overwrites the
# placeholder with the real built index.html.
internal/web/dist/*
!internal/web/dist/index.html
+9
View File
@@ -5,3 +5,12 @@ test:
.PHONY: build .PHONY: build
build: build:
go build ./... go build ./...
.PHONY: web
web:
cd web && npm ci && npm run build
rm -rf internal/web/dist
cp -r web/dist internal/web/dist
.PHONY: build-all
build-all: web build
+29 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"strings"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -14,8 +15,17 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/provider/selectel" "github.com/vasyakrg/dns-autoresolver/internal/provider/selectel"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/web"
) )
// isAPIPath reports whether path must be routed to the API router rather
// than the SPA. "/api" (no trailing slash) counts as an API path too —
// only strings.HasPrefix(path, "/api/") would otherwise miss it and fall
// through to the SPA fallback.
func isAPIPath(path string) bool {
return path == "/api" || strings.HasPrefix(path, "/api/")
}
func main() { func main() {
ctx := context.Background() ctx := context.Background()
cfg, err := config.Load() cfg, err := config.Load()
@@ -42,9 +52,27 @@ func main() {
svc := service.New(st, st, reg, cipher) svc := service.New(st, st, reg, cipher)
a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg} a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg}
apiRouter := api.NewRouter(a)
webHandler, err := web.Handler()
if err != nil {
log.Printf("web: static UI unavailable: %v", err)
}
mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isAPIPath(r.URL.Path) {
apiRouter.ServeHTTP(w, r)
return
}
if webHandler != nil {
webHandler.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
log.Printf("listening on %s", cfg.ListenAddr) log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
+22
View File
@@ -0,0 +1,22 @@
package main
import "testing"
func TestIsAPIPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/api", true},
{"/api/", true},
{"/api/domains", true},
{"/", false},
{"/domains/xyz", false},
{"/apix", false},
}
for _, c := range cases {
if got := isAPIPath(c.path); got != c.want {
t.Errorf("isAPIPath(%q) = %v, want %v", c.path, got, c.want)
}
}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><title>DNS Autoresolver</title><body>UI not built. Run: make web</body>
+43
View File
@@ -0,0 +1,43 @@
// Package web embeds the built React SPA (web/dist, copied to
// internal/web/dist by `make web` before go build/test) and serves it
// with SPA-fallback: any non-file path resolves to index.html so
// client-side routing works.
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// Handler serves the embedded SPA with fallback to index.html for
// client-side routes (any non-file path that isn't an API route).
func Handler() (http.Handler, error) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil, err
}
fileServer := http.FileServer(http.FS(sub))
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
return nil, err
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// существующий файл (ассет) — отдать как есть
p := strings.TrimPrefix(r.URL.Path, "/")
if p != "" {
if f, err := sub.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
}
// иначе — SPA index
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
}), nil
}
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerServesIndexAndSPAFallback(t *testing.T) {
h, err := Handler()
if err != nil {
t.Fatalf("handler: %v", err)
}
// корень → 200 и HTML
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("root status %d", rec.Code)
}
// неизвестный клиентский путь → SPA fallback (index.html), не 404
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/domains/xyz", nil))
if rec2.Code != http.StatusOK {
t.Fatalf("SPA fallback status %d", rec2.Code)
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DNS Autoresolver</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7026
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@fontsource-variable/hanken-grotesk": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-router-dom": "^7.18.1",
"shadcn": "^4.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.9"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+19
View File
@@ -0,0 +1,19 @@
import { render, screen, within } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { App } from "./App"
test("renders navigation and redirects to domains", () => {
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>
</QueryClientProvider>,
)
// Sidebar nav also renders a "Domains" link label, so scope the assertion
// to the routed page content to unambiguously confirm the redirect + page.
const main = screen.getByRole("main")
expect(within(main).getByText("Domains")).toBeInTheDocument()
expect(screen.getByRole("link", { name: /domains/i })).toBeInTheDocument()
})
+20
View File
@@ -0,0 +1,20 @@
import { Routes, Route, Navigate } from "react-router-dom"
import { Layout } from "@/components/Layout"
import { AccountsPage } from "@/pages/AccountsPage"
import { DomainDiffPage } from "@/pages/DomainDiffPage"
import { DomainsPage } from "@/pages/DomainsPage"
import { TemplatesPage } from "@/pages/TemplatesPage"
export function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/domains" replace />} />
<Route path="/domains" element={<DomainsPage />} />
<Route path="/domains/:id" element={<DomainDiffPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/templates" element={<TemplatesPage />} />
</Routes>
</Layout>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { api } from "./client"
import { DEFAULT_PROJECT_ID } from "@/lib/config"
beforeEach(() => { vi.restoreAllMocks() })
function mockFetch(body: unknown, ok = true, status = 200) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok, status,
json: async () => body,
text: async () => JSON.stringify(body),
} as Response)
}
describe("api client", () => {
it("lists accounts at project-scoped path", async () => {
const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }])
const accounts = await api.listAccounts()
expect(accounts).toHaveLength(1)
expect(spy).toHaveBeenCalledWith(
`/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`,
expect.objectContaining({ method: "GET" }),
)
})
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" })
const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).method).toBe("POST")
expect(String((opts as RequestInit).body)).toContain("TOKEN")
})
it("throws on non-ok response", async () => {
mockFetch({ error: "boom" }, false, 500)
await expect(api.listDomains()).rejects.toThrow()
})
it("applies with prune flag", async () => {
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true })
const [url, opts] = spy.mock.calls[0]
expect(url).toContain("/domains/d1/apply")
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
})
})
+47
View File
@@ -0,0 +1,47 @@
import { API_BASE } from "@/lib/config"
import type {
Account, CreateAccountInput, Template, CreateTemplateInput,
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
} from "./types"
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
method: "GET",
...init,
})
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
throw new Error(msg)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
listAccounts: () => req<Account[]>("/accounts"),
createAccount: (input: CreateAccountInput) =>
req<Account>("/accounts", { method: "POST", body: JSON.stringify(input) }),
deleteAccount: (id: string) => req<void>(`/accounts/${id}`, { method: "DELETE" }),
listTemplates: () => req<Template[]>("/templates"),
createTemplate: (input: CreateTemplateInput) =>
req<Template>("/templates", { method: "POST", body: JSON.stringify(input) }),
updateTemplate: (id: string, input: CreateTemplateInput) =>
req<Template>(`/templates/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteTemplate: (id: string) => req<void>(`/templates/${id}`, { method: "DELETE" }),
listDomains: () => req<Domain[]>("/domains"),
createDomain: (input: CreateDomainInput) =>
req<Domain>("/domains", { method: "POST", body: JSON.stringify(input) }),
deleteDomain: (id: string) => req<void>(`/domains/${id}`, { method: "DELETE" }),
importZones: (accountId: string) =>
req<Domain[]>(`/accounts/${accountId}/import`, { method: "POST" }),
setDomainTemplate: (id: string, templateId: string | null) =>
req<Domain>(`/domains/${id}`, { method: "PATCH", body: JSON.stringify({ templateId }) }),
checkDomain: (id: string) => req<ChangesetResponse>(`/domains/${id}/check`),
applyDomain: (id: string, body: ApplyRequest) =>
req<ChangesetResponse>(`/domains/${id}/apply`, { method: "POST", body: JSON.stringify(body) }),
}
+36
View File
@@ -0,0 +1,36 @@
export interface Account { id: string; provider: string; comment: string }
export interface CreateAccountInput { provider: string; secret: string; comment: string }
export interface RecordDTO { type: string; name: string; ttl: number; values: string[] }
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
export interface CreateTemplateInput { name: string; records: RecordDTO[] }
export interface Domain {
id: string
providerAccountId: string
zoneName: string
zoneId: string
templateId?: string | null // Go omitempty: поле может отсутствовать (undefined), а не null
}
export interface CreateDomainInput {
providerAccountId: string
zoneName: string
zoneId: string
templateId?: string | null
}
export interface RecordView {
kind: string // add | update | delete | in_sync
type: string
name: string
desired?: string[]
actual?: string[]
readOnly: boolean
}
export interface ChangesetResponse {
updates: RecordView[]
prunes: RecordView[]
readOnly: RecordView[]
inSyncCount: number
}
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
+28
View File
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react"
import { DiffView } from "./DiffView"
import type { ChangesetResponse } from "@/api/types"
const cs: ChangesetResponse = {
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
inSyncCount: 3,
}
test("renders all sections with counts", () => {
render(<DiffView changeset={cs} />)
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
// "old.example.com." both contain "example.com." as a trailing substring,
// so the unanchored pattern matches all three rows and getByText throws
// "multiple elements found". Anchoring targets the read-only apex record
// specifically, which is what this assertion is actually verifying.
expect(screen.getByText(/^example\.com\.$/)).toBeInTheDocument()
expect(screen.getByText(/3/)).toBeInTheDocument() // in-sync count
})
test("marks read-only records", () => {
render(<DiffView changeset={cs} />)
expect(screen.getByText(/NS/)).toBeInTheDocument()
})
+160
View File
@@ -0,0 +1,160 @@
import type { ReactNode } from "react"
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import type { ChangesetResponse, RecordView } from "@/api/types"
type Tone = "update" | "delete" | "readonly"
const TONE_META: Record<
Tone,
{ label: string; empty: string; icon: typeof Pencil; dot: string; ring: string }
> = {
update: {
label: "Updates",
empty: "Нет изменений — все значения совпадают.",
icon: Pencil,
dot: "var(--diff-update)",
ring: "ring-[color-mix(in_oklch,var(--diff-update),transparent_78%)]",
},
delete: {
label: "Prunes",
empty: "Нечего удалять.",
icon: Trash2,
dot: "var(--diff-delete)",
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
},
readonly: {
label: "Read-only",
empty: "Нет read-only записей.",
icon: Lock,
dot: "var(--diff-readonly)",
ring: "ring-[color-mix(in_oklch,var(--diff-readonly),transparent_82%)]",
},
}
function Values({ values }: { values?: string[] }) {
if (!values || values.length === 0) {
return <span className="text-muted-foreground/50"></span>
}
return <>{values.join(", ")}</>
}
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
const meta = TONE_META[tone]
const showArrow = tone !== "delete"
return (
<div
className={cn(
"group/row flex items-center gap-3 border-l-2 px-3 py-2.5 transition-colors",
"hover:bg-foreground/[0.025]",
)}
style={{ borderLeftColor: meta.dot }}
>
<Badge
variant="outline"
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
>
{record.type}
</Badge>
<span className="font-dns min-w-0 flex-1 truncate text-sm text-foreground">
{record.name}
</span>
<span className="font-dns hidden shrink-0 items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Values values={record.actual} />
{showArrow && (
<>
<ArrowRight className="size-3 text-muted-foreground/50" strokeWidth={1.75} />
<span style={{ color: meta.dot }}>
<Values values={record.desired} />
</span>
</>
)}
</span>
{record.readOnly && (
<Badge
variant="secondary"
className="ml-1 shrink-0 gap-1 bg-muted text-[10px] text-muted-foreground"
>
<Lock className="size-2.5" strokeWidth={2} />
read-only
</Badge>
)}
</div>
)
}
function Section({
tone,
records,
}: {
tone: Tone
records: RecordView[]
}) {
const meta = TONE_META[tone]
const Icon = meta.icon
return (
<section aria-label={meta.label} className="flex flex-col gap-2">
<header className="flex items-center gap-2 px-0.5">
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
{meta.label}
</h2>
<Badge variant="outline" className="font-dns h-4.5 px-1.5 text-[10px] text-muted-foreground">
{records.length}
</Badge>
</header>
{records.length === 0 ? (
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground/70">
{meta.empty}
</p>
) : (
<div
className={cn(
"flex flex-col divide-y divide-border rounded-lg bg-card ring-1",
meta.ring,
)}
>
{records.map((record, i) => (
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
))}
</div>
)}
</section>
)
}
export function DiffView({
changeset,
footerExtra,
}: {
changeset: ChangesetResponse
footerExtra?: ReactNode
}) {
return (
<div className="flex flex-col gap-6">
<Section tone="update" records={changeset.updates} />
<Section tone="delete" records={changeset.prunes} />
<Section tone="readonly" records={changeset.readOnly} />
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CircleCheck
className="size-3.5"
strokeWidth={1.75}
style={{ color: "var(--diff-insync)" }}
/>
<span className="font-dns">{changeset.inSyncCount}</span>
<span>record{changeset.inSyncCount === 1 ? "" : "s"} in sync</span>
</div>
{footerExtra}
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react"
import { NavLink, useLocation } from "react-router-dom"
import { Globe, Users, LayoutTemplate, SquareTerminal } from "lucide-react"
import { cn } from "@/lib/utils"
const NAV = [
{ to: "/domains", label: "Domains", icon: Globe },
{ to: "/accounts", label: "Accounts", icon: Users },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
] as const
export function Layout({ children }: { children: ReactNode }) {
const location = useLocation()
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
<div className="flex items-center gap-2 border-b border-sidebar-border px-4 py-4">
<SquareTerminal className="size-4 text-primary" strokeWidth={1.75} />
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold tracking-tight">
DNS Autoresolver
</span>
<span className="font-dns text-[10px] tracking-wider text-muted-foreground uppercase">
console
</span>
</div>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3">
{NAV.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"group flex items-center gap-2.5 rounded-md border-l-2 border-transparent px-2.5 py-2 text-sm font-medium text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
isActive &&
"border-primary bg-sidebar-accent text-sidebar-accent-foreground",
)
}
>
<Icon className="size-4 shrink-0" strokeWidth={1.75} />
<span className="flex-1">{label}</span>
<span className="font-dns text-[10px] text-muted-foreground/60 group-hover:text-muted-foreground">
{to}
</span>
</NavLink>
))}
</nav>
<div className="flex items-center justify-between border-t border-sidebar-border px-4 py-3 font-dns text-[11px] text-muted-foreground">
<span>v0.1.0-dev</span>
<span className="flex items-center gap-1.5">
<span
className="size-1.5 rounded-full"
style={{ background: "var(--diff-insync)" }}
aria-hidden
/>
in sync
</span>
</div>
</aside>
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-11 shrink-0 items-center border-b border-border px-6">
<span className="font-dns text-xs text-muted-foreground">
{location.pathname}
</span>
</header>
<main className="flex-1 overflow-auto">{children}</main>
</div>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useState } from "react"
import { test, expect, vi } from "vitest"
import { RecordEditor } from "./RecordEditor"
import type { RecordDTO } from "@/api/types"
function Harness({
initial,
onChange,
}: {
initial: RecordDTO[]
onChange: (records: RecordDTO[]) => void
}) {
const [value, setValue] = useState<RecordDTO[]>(initial)
return (
<RecordEditor
value={value}
onChange={(next) => {
setValue(next)
onChange(next)
}}
/>
)
}
test("добавление записи вызывает onChange с новой пустой записью типа A", async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Harness initial={[]} onChange={onChange} />)
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
expect(onChange).toHaveBeenCalledWith([{ type: "A", name: "", ttl: 3600, values: [""] }])
})
test("изменение полей записи вызывает onChange с корректным массивом", async () => {
const onChange = vi.fn()
render(
<Harness
initial={[{ type: "A", name: "", ttl: 3600, values: [""] }]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
fireEvent.change(screen.getByLabelText(/ttl записи 1/i), { target: { value: "300" } })
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
target: { value: "192.0.2.1" },
})
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{ type: "A", name: "www", ttl: 300, values: ["192.0.2.1"] },
]),
)
})
test("несколько значений в textarea дают несколько элементов values", async () => {
const onChange = vi.fn()
render(
<Harness
initial={[{ type: "NS", name: "@", ttl: 3600, values: [""] }]}
onChange={onChange}
/>,
)
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
target: { value: "ns1.example.com.\nns2.example.com." },
})
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{
type: "NS",
name: "@",
ttl: 3600,
values: ["ns1.example.com.", "ns2.example.com."],
},
]),
)
})
test("выбор типа SRV и составное значение prio weight port target", async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<Harness
initial={[{ type: "A", name: "_sip._tcp", ttl: 3600, values: [""] }]}
onChange={onChange}
/>,
)
await user.click(screen.getByRole("combobox", { name: /тип записи 1/i }))
await user.click(await screen.findByRole("option", { name: /^srv$/i }))
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
target: { value: "10 5 5060 sipserver.example.com." },
})
await waitFor(() =>
expect(onChange).toHaveBeenLastCalledWith([
{
type: "SRV",
name: "_sip._tcp",
ttl: 3600,
values: ["10 5 5060 sipserver.example.com."],
},
]),
)
})
test("удаление записи вызывает onChange без удалённой записи", async () => {
const onChange = vi.fn()
const user = userEvent.setup()
const initial: RecordDTO[] = [
{ type: "A", name: "a", ttl: 3600, values: ["1.1.1.1"] },
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
]
render(<Harness initial={initial} onChange={onChange} />)
await user.click(screen.getByRole("button", { name: /удалить запись 1/i }))
expect(onChange).toHaveBeenLastCalledWith([
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
])
})
test("mono-шрифт применён к полям name и values", () => {
render(
<Harness
initial={[{ type: "A", name: "www", ttl: 3600, values: ["1.2.3.4"] }]}
onChange={vi.fn()}
/>,
)
expect(screen.getByLabelText(/имя записи 1/i)).toHaveClass("font-dns")
expect(screen.getByLabelText(/значения записи 1/i)).toHaveClass("font-dns")
})
+111
View File
@@ -0,0 +1,111 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { RecordDTO } from "@/api/types"
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "SOA"] as const
const typeItems = RECORD_TYPES.map((t) => ({ value: t, label: t }))
const DEFAULT_TTL = 3600
function emptyRecord(): RecordDTO {
return { type: "A", name: "", ttl: DEFAULT_TTL, values: [""] }
}
export function RecordEditor({
value,
onChange,
}: {
value: RecordDTO[]
onChange: (records: RecordDTO[]) => void
}) {
function updateRecord(index: number, patch: Partial<RecordDTO>) {
onChange(value.map((r, i) => (i === index ? { ...r, ...patch } : r)))
}
function addRecord() {
onChange([...value, emptyRecord()])
}
function removeRecord(index: number) {
onChange(value.filter((_, i) => i !== index))
}
return (
<div className="flex flex-col gap-2">
{value.map((record, index) => (
<div
key={index}
className="flex flex-col gap-2 rounded-lg border border-border bg-background/40 p-2.5 sm:flex-row sm:items-start"
>
<Select
items={typeItems}
value={record.type}
onValueChange={(v) => updateRecord(index, { type: v as string })}
>
<SelectTrigger aria-label={`Тип записи ${index + 1}`} size="sm" className="font-dns sm:w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{typeItems.map((item) => (
<SelectItem key={item.value} value={item.value} className="font-dns">
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
aria-label={`Имя записи ${index + 1}`}
className="font-dns sm:flex-1"
placeholder="www"
value={record.name}
onChange={(e) => updateRecord(index, { name: e.target.value })}
/>
<Input
aria-label={`TTL записи ${index + 1}`}
type="number"
min={0}
className="font-dns sm:w-24"
value={record.ttl}
onChange={(e) => updateRecord(index, { ttl: Number(e.target.value) })}
/>
<Textarea
aria-label={`Значения записи ${index + 1}`}
className="font-dns sm:flex-1"
placeholder="192.0.2.1"
rows={1}
value={record.values.join("\n")}
onChange={(e) => updateRecord(index, { values: e.target.value.split("\n") })}
/>
<Button
type="button"
variant="destructive"
size="icon-sm"
aria-label={`Удалить запись ${index + 1}`}
onClick={() => removeRecord(index)}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addRecord} className="self-start">
<Plus className="size-3.5" strokeWidth={1.75} />
Добавить запись
</Button>
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+27
View File
@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+236
View File
@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+81
View File
@@ -0,0 +1,81 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { api } from "@/api/client"
import type { CreateAccountInput, CreateTemplateInput, ApplyRequest } from "@/api/types"
export function useAccounts() {
return useQuery({ queryKey: ["accounts"], queryFn: api.listAccounts })
}
export function useCreateAccount() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateAccountInput) => api.createAccount(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
})
}
export function useDeleteAccount() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteAccount(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
})
}
export function useTemplates() {
return useQuery({ queryKey: ["templates"], queryFn: api.listTemplates })
}
export function useCreateTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateTemplateInput) => api.createTemplate(input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useUpdateTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => api.updateTemplate(id, input),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useDeleteTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteTemplate(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
})
}
export function useDomains() {
return useQuery({ queryKey: ["domains"], queryFn: api.listDomains })
}
export function useImportZones() {
const qc = useQueryClient()
return useMutation({
mutationFn: (accountId: string) => api.importZones(accountId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useSetDomainTemplate() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => api.setDomainTemplate(id, templateId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useDeleteDomain() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteDomain(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
})
}
export function useCheckDomain(id: string) {
return useQuery({ queryKey: ["check", id], queryFn: () => api.checkDomain(id), enabled: !!id })
}
export function useApplyDomain(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ApplyRequest) => api.applyDomain(id, body),
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", id] }),
})
}
+194
View File
@@ -0,0 +1,194 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-ui);
--font-sans: var(--font-ui);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-diff-add: var(--diff-add);
--color-diff-update: var(--diff-update);
--color-diff-delete: var(--diff-delete);
--color-diff-insync: var(--diff-insync);
--color-diff-readonly: var(--diff-readonly);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
/* Typography: distinctive UI grotesk + mono for DNS-shaped data. */
--font-ui: "Hanken Grotesk Variable", ui-sans-serif, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", monospace;
--background: oklch(0.98 0.003 260);
--foreground: oklch(0.2 0.012 260);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.012 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.012 260);
--primary: oklch(0.45 0.1 224);
--primary-foreground: oklch(0.98 0.005 260);
--secondary: oklch(0.94 0.006 260);
--secondary-foreground: oklch(0.25 0.012 260);
--muted: oklch(0.94 0.006 260);
--muted-foreground: oklch(0.5 0.012 260);
--accent: oklch(0.92 0.015 224);
--accent-foreground: oklch(0.25 0.012 260);
--destructive: oklch(0.58 0.21 20);
--border: oklch(0.88 0.006 260);
--input: oklch(0.88 0.006 260);
--ring: oklch(0.6 0.1 224 / 50%);
--chart-1: oklch(0.72 0.15 155);
--chart-2: oklch(0.8 0.14 85);
--chart-3: oklch(0.68 0.19 20);
--chart-4: oklch(0.55 0.1 224);
--chart-5: oklch(0.5 0.02 260);
--radius: 0.375rem;
--sidebar: oklch(0.96 0.005 260);
--sidebar-foreground: oklch(0.2 0.012 260);
--sidebar-primary: oklch(0.45 0.1 224);
--sidebar-primary-foreground: oklch(0.98 0.005 260);
--sidebar-accent: oklch(0.92 0.015 224);
--sidebar-accent-foreground: oklch(0.25 0.012 260);
--sidebar-border: oklch(0.88 0.006 260);
--sidebar-ring: oklch(0.6 0.1 224 / 50%);
/* Diff semantics — used across the domain-diff console (Task 3-6). */
--diff-add: oklch(0.72 0.15 155); /* emerald */
--diff-update: oklch(0.8 0.14 85); /* amber */
--diff-delete: oklch(0.68 0.19 20); /* rose */
--diff-insync: oklch(0.55 0.02 260); /* muted */
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
}
/* "Refined technical console" — dark by default (html.dark). Cool slate
surfaces, a single steel-cyan accent (never purple), hairline borders,
and a faint drafting-table grid for atmosphere instead of noise/blur. */
.dark {
--background: oklch(0.16 0.011 258);
--foreground: oklch(0.94 0.006 258);
--card: oklch(0.195 0.012 258);
--card-foreground: oklch(0.94 0.006 258);
--popover: oklch(0.185 0.012 258);
--popover-foreground: oklch(0.94 0.006 258);
--primary: oklch(0.74 0.11 224);
--primary-foreground: oklch(0.16 0.02 258);
--secondary: oklch(0.26 0.012 258);
--secondary-foreground: oklch(0.94 0.006 258);
--muted: oklch(0.23 0.011 258);
--muted-foreground: oklch(0.64 0.016 258);
--accent: oklch(0.28 0.03 224);
--accent-foreground: oklch(0.94 0.006 258);
--destructive: oklch(0.68 0.19 20);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.74 0.11 224 / 45%);
--chart-1: oklch(0.72 0.15 155);
--chart-2: oklch(0.8 0.14 85);
--chart-3: oklch(0.68 0.19 20);
--chart-4: oklch(0.74 0.11 224);
--chart-5: oklch(0.55 0.02 258);
--sidebar: oklch(0.135 0.012 258);
--sidebar-foreground: oklch(0.88 0.008 258);
--sidebar-primary: oklch(0.74 0.11 224);
--sidebar-primary-foreground: oklch(0.16 0.02 258);
--sidebar-accent: oklch(0.22 0.014 258);
--sidebar-accent-foreground: oklch(0.94 0.006 258);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.74 0.11 224 / 45%);
--diff-add: oklch(0.72 0.15 155);
--diff-update: oklch(0.8 0.14 85);
--diff-delete: oklch(0.68 0.19 20);
--diff-insync: oklch(0.55 0.02 258);
--diff-readonly: oklch(0.42 0.014 258);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-ui);
background-image:
linear-gradient(to right, oklch(1 0 0 / 3%) 1px, transparent 1px),
linear-gradient(to bottom, oklch(1 0 0 / 3%) 1px, transparent 1px);
background-size: 32px 32px;
background-attachment: fixed;
}
::selection {
background: oklch(0.74 0.11 224 / 30%);
color: var(--foreground);
}
/* Thin, muted scrollbars — a console tool doesn't shout. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
}
/* DNS-shaped data (record names, TTLs, IPs, zone files) reads in mono. */
.font-dns {
font-family: var(--font-mono);
font-feature-settings: "tnum" 1, "zero" 1;
letter-spacing: -0.01em;
}
+2
View File
@@ -0,0 +1,2 @@
export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"
export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}`
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+21
View File
@@ -0,0 +1,21 @@
import "@fontsource-variable/hanken-grotesk/index.css"
import "@fontsource/ibm-plex-mono/400.css"
import "@fontsource/ibm-plex-mono/500.css"
import "./index.css"
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { BrowserRouter } from "react-router-dom"
import { App } from "./App"
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)
+103
View File
@@ -0,0 +1,103 @@
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 { AccountsPage } from "./AccountsPage"
import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest"
import type { Account } from "@/api/types"
const accounts: Account[] = [
{ id: "acc1", provider: "selectel", comment: "Main" },
{ id: "acc2", provider: "selectel", comment: "Backup" },
]
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/accounts"]}>
<AccountsPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
})
test("отрисовывает список учёток без секрета", async () => {
renderPage()
expect(await screen.findByText("Main")).toBeInTheDocument()
expect(screen.getByText("Backup")).toBeInTheDocument()
expect(screen.getAllByText("selectel").length).toBeGreaterThan(0)
})
test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => {
const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({
id: "acc3",
provider: "selectel",
comment: "New account",
})
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
const secretInput = screen.getByLabelText(/api-ключ/i)
expect(secretInput).toHaveAttribute("type", "password")
await user.type(secretInput, "super-secret-token-123")
await user.type(screen.getByLabelText(/комментарий/i), "New account")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith({
provider: "selectel",
secret: "super-secret-token-123",
comment: "New account",
}),
)
await waitFor(() => expect(secretInput).toHaveValue(""))
expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument()
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
})
test("содержит ссылку-инструкцию получения ключа Selectel", async () => {
renderPage()
await screen.findByText("Main")
const link = screen.getByRole("link", { name: /selectel/i })
expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru"))
})
test("ошибка создания учётки отображается пользователю", async () => {
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку"))
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz")
await user.type(screen.getByLabelText(/комментарий/i), "Test")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку")
})
test("удаление учётки вызывает api.deleteAccount", async () => {
const deleteSpy = vi.spyOn(api, "deleteAccount").mockResolvedValue(undefined)
vi.spyOn(window, "confirm").mockReturnValue(true)
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
await user.click(screen.getByRole("button", { name: /удалить.*main/i }))
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1"))
})
+201
View File
@@ -0,0 +1,201 @@
import { useId } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { ExternalLink, Inbox, KeyRound, Loader2, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi"
const createAccountSchema = z.object({
provider: z.literal("selectel"),
secret: z.string().min(1, "Укажите API-ключ"),
comment: z.string().min(1, "Укажите комментарий"),
})
type CreateAccountForm = z.infer<typeof createAccountSchema>
export function AccountsPage() {
const accounts = useAccounts()
const createAccount = useCreateAccount()
const deleteAccount = useDeleteAccount()
const secretFieldId = useId()
const commentFieldId = useId()
const accountList = accounts.data ?? []
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateAccountForm>({
resolver: zodResolver(createAccountSchema),
defaultValues: { provider: "selectel", secret: "", comment: "" },
})
function onSubmit(values: CreateAccountForm) {
createAccount.mutate(values, {
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }),
})
}
function onDelete(id: string, comment: string) {
if (window.confirm(`Удалить учётную запись «${comment}»? Действие необратимо.`)) {
deleteAccount.mutate(id)
}
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
providers
</span>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Учётные записи</h1>
</header>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
<Field className="sm:max-w-56">
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
<FieldContent>
<Controller
control={control}
name="secret"
render={({ field }) => (
<Input
{...field}
id={secretFieldId}
type="password"
autoComplete="off"
placeholder="••••••••••••"
aria-invalid={!!errors.secret}
/>
)}
/>
<FieldError errors={[errors.secret]} />
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor={commentFieldId}>Комментарий</FieldLabel>
<FieldContent>
<Controller
control={control}
name="comment"
render={({ field }) => (
<Input
{...field}
id={commentFieldId}
placeholder="Например, основной аккаунт"
aria-invalid={!!errors.comment}
/>
)}
/>
<FieldError errors={[errors.comment]} />
</FieldContent>
</Field>
</FieldGroup>
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5">
<KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
<span>
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся
в панели управления Selectel раздел «API-ключи» и используется только для
запроса, храниться в открытом виде он не будет.{" "}
<a
href="https://my.selectel.ru/profile/apikeys"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
Получить ключ на my.selectel.ru
<ExternalLink className="size-3" strokeWidth={1.75} />
</a>
</span>
</FieldDescription>
</FieldSet>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{createAccount.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{createAccount.error.message}
</span>
)}
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">
{createAccount.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Plus className="size-4" strokeWidth={1.75} />
)}
Добавить учётку
</Button>
</div>
</form>
{deleteAccount.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{deleteAccount.error.message}
</span>
)}
{accountList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Учётных записей пока нет добавьте первую выше.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Провайдер</TableHead>
<TableHead>Комментарий</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accountList.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-dns">{a.provider}</TableCell>
<TableCell>{a.comment}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить учётку ${a.comment}`}
onClick={() => onDelete(a.id, a.comment)}
disabled={deleteAccount.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainDiffPage } from "./DomainDiffPage"
import { api } from "@/api/client"
import { vi } from "vitest"
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/domains/d1"]}>
<Routes><Route path="/domains/:id" element={<DomainDiffPage />} /></Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
readOnly: [], inSyncCount: 0,
})
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
const user = userEvent.setup()
renderPage()
const applyBtn = await screen.findByRole("button", { name: /apply/i })
await user.click(applyBtn)
await waitFor(() => expect(applySpy).toHaveBeenCalled())
expect(applySpy.mock.calls[0][1]).toEqual({ applyUpdates: true, applyPrunes: false })
// включить prune и применить снова
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
await user.click(pruneToggle)
await user.click(screen.getByRole("button", { name: /apply/i }))
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
expect(applySpy.mock.calls[1][1]).toEqual({ applyUpdates: true, applyPrunes: true })
})
+142
View File
@@ -0,0 +1,142 @@
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 { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi"
import { cn } from "@/lib/utils"
export function DomainDiffPage() {
const { id = "" } = useParams()
const check = useCheckDomain(id)
const apply = useApplyDomain(id)
const [applyPrunes, setApplyPrunes] = useState(false)
const pruneCheckboxId = useId()
const changeset = check.data
const hasPrunes = (changeset?.prunes.length ?? 0) > 0
const hasUpdates = (changeset?.updates.length ?? 0) > 0
const pruneWarning = applyPrunes && hasPrunes
function onApply() {
apply.mutate({ applyUpdates: true, applyPrunes })
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
domain / check
</span>
<h1 className="font-dns text-xl font-semibold tracking-tight text-foreground">
{id}
</h1>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => check.refetch()}
disabled={check.isFetching}
>
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
Recheck
</Button>
</header>
{check.isPending && (
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
Вычисляю дифф
</div>
)}
{check.isError && (
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<div className="flex flex-col gap-1">
<span className="font-medium">Не удалось получить дифф</span>
<span className="font-dns text-xs opacity-90">{check.error.message}</span>
</div>
</div>
)}
{changeset && (
<>
<DiffView changeset={changeset} />
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
<Label
htmlFor={pruneCheckboxId}
className="flex items-start gap-2.5 text-sm font-normal"
>
<Checkbox
id={pruneCheckboxId}
aria-label="prune — удалить лишние записи"
checked={applyPrunes}
onCheckedChange={(v) => setApplyPrunes(v === true)}
className="mt-0.5"
style={
applyPrunes
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
: undefined
}
/>
<span className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
Prune удалить записи, которых нет в шаблоне
</span>
<span className="text-xs text-muted-foreground">
По умолчанию выключено. Apply меняет только записи из шаблона.
</span>
</span>
</Label>
{pruneWarning && (
<div
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
style={{
color: "var(--diff-delete)",
background: "color-mix(in oklch, var(--diff-delete), transparent 90%)",
}}
role="alert"
>
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
<span>
Будет безвозвратно удалено записей:{" "}
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
</span>
</div>
)}
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{apply.isError ? (
<span className="font-dns text-xs text-destructive">{apply.error.message}</span>
) : apply.isSuccess ? (
<span className="font-dns text-xs" style={{ color: "var(--diff-add)" }}>
Применено
</span>
) : (
<span className="text-xs text-muted-foreground">
{hasUpdates || (applyPrunes && hasPrunes)
? "Готово к применению"
: "Изменений для применения нет"}
</span>
)}
<Button onClick={onApply} disabled={apply.isPending}>
{apply.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Play className="size-4" strokeWidth={1.75} />
)}
Apply
</Button>
</div>
</div>
</>
)}
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainsPage } from "./DomainsPage"
import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest"
import type { Account, Domain, Template } from "@/api/types"
const accounts: Account[] = [
{ id: "acc1", provider: "selectel", comment: "Main" },
{ id: "acc2", provider: "cloudflare", comment: "Backup" },
]
const templates: Template[] = [
{ id: "t1", name: "Standard", records: [], version: 1 },
{ 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" },
]
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/domains"]}>
<Routes>
<Route path="/domains" element={<DomainsPage />} />
<Route path="/domains/:id" element={<div>diff page</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.spyOn(api, "listDomains").mockResolvedValue(domains)
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
})
test("отрисовывает домены и ссылку на diff-страницу", async () => {
renderPage()
expect(await screen.findByText("example.com.")).toBeInTheDocument()
expect(screen.getByText("test.org.")).toBeInTheDocument()
const links = screen.getAllByRole("link", { name: /diff/i })
expect(links.length).toBe(2)
expect(links[0]).toHaveAttribute("href", "/domains/d1")
expect(links[1]).toHaveAttribute("href", "/domains/d2")
})
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /учётн/i }))
await user.click(await screen.findByRole("option", { name: /cloudflare/i }))
await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2"))
})
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1"))
})
test("ошибка привязки шаблона отображается пользователю", async () => {
vi.spyOn(api, "setDomainTemplate").mockRejectedValue(new Error("Не удалось привязать шаблон"))
const user = userEvent.setup()
renderPage()
await screen.findByText("example.com.")
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось привязать шаблон")
})
test("пустое состояние при отсутствии доменов", async () => {
vi.spyOn(api, "listDomains").mockResolvedValue([])
renderPage()
expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument()
})
+194
View File
@@ -0,0 +1,194 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useAccounts,
useDeleteDomain,
useDomains,
useImportZones,
useSetDomainTemplate,
useTemplates,
} from "@/hooks/useApi"
const NO_TEMPLATE = "__none__"
export function DomainsPage() {
const domains = useDomains()
const accounts = useAccounts()
const templates = useTemplates()
const importZones = useImportZones()
const setTemplate = useSetDomainTemplate()
const deleteDomain = useDeleteDomain()
const accountList = accounts.data ?? []
const templateList = templates.data ?? []
const domainList = domains.data ?? []
const [importAccountId, setImportAccountId] = useState<string | null>(null)
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
const accountItems = accountList.map((a) => ({
value: a.id,
label: `${a.provider} · ${a.comment}`,
}))
function accountLabel(id: string) {
const acc = accountList.find((a) => a.id === id)
return acc ? `${acc.provider} · ${acc.comment}` : id
}
function onImport() {
if (!selectedImportAccount) return
importZones.mutate(selectedImportAccount)
}
function onTemplateChange(domainId: string, value: unknown) {
const templateId = value === NO_TEMPLATE ? null : (value as string)
setTemplate.mutate({ id: domainId, templateId })
}
function onDelete(domainId: string, zoneName: string) {
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
deleteDomain.mutate(domainId)
}
}
return (
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
zones
</span>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Domains</h1>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Учётная запись</span>
<Select
items={accountItems}
value={selectedImportAccount}
onValueChange={(v) => setImportAccountId(v as string)}
>
<SelectTrigger aria-label="Учётная запись для импорта" className="min-w-56">
<SelectValue placeholder="Выберите учётку" />
</SelectTrigger>
<SelectContent>
{accountItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={onImport} disabled={!selectedImportAccount || importZones.isPending}>
{importZones.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Upload className="size-4" strokeWidth={1.75} />
)}
Импортировать зоны
</Button>
{importZones.isError && (
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
)}
</div>
{setTemplate.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{setTemplate.error?.message}
</span>
)}
{deleteDomain.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{deleteDomain.error?.message}
</span>
)}
{domainList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Доменов пока нет импортируйте зоны из учётной записи.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zone</TableHead>
<TableHead>Учётка</TableHead>
<TableHead>Шаблон</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{domainList.map((d) => {
const templateItems = [
{ value: NO_TEMPLATE, label: "Без шаблона" },
...templateList.map((t) => ({ value: t.id, label: t.name })),
]
return (
<TableRow key={d.id}>
<TableCell className="font-dns">{d.zoneName}</TableCell>
<TableCell className="font-dns text-xs text-muted-foreground">
{accountLabel(d.providerAccountId)}
</TableCell>
<TableCell>
<Select
items={templateItems}
value={d.templateId ?? NO_TEMPLATE}
onValueChange={(v) => onTemplateChange(d.id, v)}
>
<SelectTrigger aria-label={`Шаблон: ${d.zoneName}`} size="sm" className="min-w-40">
<SelectValue placeholder="Без шаблона" />
</SelectTrigger>
<SelectContent>
{templateItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
Diff
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить ${d.zoneName}`}
onClick={() => onDelete(d.id, d.zoneName)}
disabled={deleteDomain.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { render, screen, waitFor, within, fireEvent } 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 { TemplatesPage } from "./TemplatesPage"
import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest"
import type { Template } from "@/api/types"
const templates: Template[] = [
{
id: "t1",
name: "Standard",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
version: 1,
},
{ id: "t2", name: "Minimal", records: [], version: 1 },
]
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/templates"]}>
<TemplatesPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
})
test("отрисовывает список шаблонов с числом записей", async () => {
renderPage()
await screen.findByText("Standard")
const standardRow = screen.getByRole("row", { name: /standard/i })
expect(within(standardRow).getByText("1")).toBeInTheDocument()
const minimalRow = screen.getByRole("row", { name: /minimal/i })
expect(within(minimalRow).getByText("0")).toBeInTheDocument()
})
test("создание шаблона с записью вызывает api.createTemplate с {name, records}", async () => {
const createSpy = vi.spyOn(api, "createTemplate").mockResolvedValue({
id: "t3",
name: "New",
records: [],
version: 1,
})
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
fireEvent.change(screen.getByLabelText(/значения записи 1/i), { target: { value: "1.1.1.1" } })
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith({
name: "New",
records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }],
}),
)
})
test("редактирование шаблона вызывает api.updateTemplate с обновлённым именем", async () => {
const updateSpy = vi.spyOn(api, "updateTemplate").mockResolvedValue(templates[0])
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.click(screen.getByRole("button", { name: /редактировать standard/i }))
const nameInput = screen.getByLabelText(/имя шаблона/i)
await user.clear(nameInput)
await user.type(nameInput, "Standard v2")
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() =>
expect(updateSpy).toHaveBeenCalledWith("t1", {
name: "Standard v2",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
}),
)
})
test("удаление шаблона вызывает api.deleteTemplate", async () => {
const deleteSpy = vi.spyOn(api, "deleteTemplate").mockResolvedValue(undefined)
vi.spyOn(window, "confirm").mockReturnValue(true)
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i }))
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1"))
})
test("ошибка создания шаблона отображается пользователю", async () => {
vi.spyOn(api, "createTemplate").mockRejectedValue(new Error("Не удалось создать шаблон"))
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "Broken")
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать шаблон")
})
test("запись с нетронутым (пустым) values не уходит в api.createTemplate, показывается ошибка", async () => {
const createSpy = vi.spyOn(api, "createTemplate").mockClear()
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
// значения записи намеренно не заполняются — остаётся дефолтная пустая строка
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
expect(await screen.findByRole("alert")).toHaveTextContent(/заполните имя и значения/i)
expect(createSpy).not.toHaveBeenCalled()
})
test("сабмит с пустым именем записи показывает ошибку и не вызывает api.createTemplate", async () => {
const createSpy = vi.spyOn(api, "createTemplate").mockClear()
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
fireEvent.change(screen.getByLabelText(/значения записи 1/i), { target: { value: "1.1.1.1" } })
// имя записи намеренно оставлено пустым
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
expect(await screen.findByRole("alert")).toHaveTextContent(/заполните имя и значения/i)
expect(createSpy).not.toHaveBeenCalled()
})
test("пустое состояние при отсутствии шаблонов", async () => {
vi.spyOn(api, "listTemplates").mockResolvedValue([])
renderPage()
expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument()
})
+246
View File
@@ -0,0 +1,246 @@
import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Inbox, Loader2, Pencil, Save, Trash2, X } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RecordEditor } from "@/components/RecordEditor"
import { useCreateTemplate, useDeleteTemplate, useTemplates, useUpdateTemplate } from "@/hooks/useApi"
import type { Template } from "@/api/types"
const recordSchema = z.object({
type: z.string().min(1),
name: z.string().min(1, "Укажите имя записи"),
ttl: z.number().int("TTL — целое число").nonnegative("TTL не может быть отрицательным"),
values: z
.array(z.string().trim().min(1, "Значение не может быть пустым"))
.min(1, "Добавьте хотя бы одно значение"),
})
const templateFormSchema = z.object({
name: z.string().min(1, "Укажите имя шаблона"),
records: z.array(recordSchema),
})
type TemplateForm = z.infer<typeof templateFormSchema>
const EMPTY_FORM: TemplateForm = { name: "", records: [] }
function sanitizeRecords(records: TemplateForm["records"]) {
return records
.map((record) => ({
...record,
values: record.values.map((v) => v.trim()).filter(Boolean),
}))
.filter((record) => record.values.length > 0)
}
export function TemplatesPage() {
const templates = useTemplates()
const createTemplate = useCreateTemplate()
const updateTemplate = useUpdateTemplate()
const deleteTemplate = useDeleteTemplate()
const nameFieldId = useId()
const [editingId, setEditingId] = useState<string | null>(null)
const templateList = templates.data ?? []
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<TemplateForm>({
resolver: zodResolver(templateFormSchema),
defaultValues: EMPTY_FORM,
})
function onEdit(template: Template) {
setEditingId(template.id)
reset({ name: template.name, records: template.records })
}
function onCancelEdit() {
setEditingId(null)
reset(EMPTY_FORM)
}
function onSubmit(values: TemplateForm) {
const input = { ...values, records: sanitizeRecords(values.records) }
if (editingId) {
updateTemplate.mutate(
{ id: editingId, input },
{
onSuccess: () => {
setEditingId(null)
reset(EMPTY_FORM)
},
},
)
} else {
createTemplate.mutate(input, { onSuccess: () => reset(EMPTY_FORM) })
}
}
function onDelete(id: string, name: string) {
if (window.confirm(`Удалить шаблон «${name}»? Действие необратимо.`)) {
if (editingId === id) onCancelEdit()
deleteTemplate.mutate(id)
}
}
const saveMutation = editingId ? updateTemplate : createTemplate
const hasFormErrors = !!errors.name || !!errors.records
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
templates
</span>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Шаблоны</h1>
</header>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field className="sm:max-w-72">
<FieldLabel htmlFor={nameFieldId}>Имя шаблона</FieldLabel>
<FieldContent>
<Controller
control={control}
name="name"
render={({ field }) => (
<Input
{...field}
id={nameFieldId}
placeholder="Например, standard"
aria-invalid={!!errors.name}
/>
)}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field>
<FieldLabel>Записи</FieldLabel>
<FieldContent>
<Controller
control={control}
name="records"
render={({ field }) => (
<RecordEditor value={field.value} onChange={field.onChange} />
)}
/>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{hasFormErrors ? (
<span role="alert" className="font-dns text-xs text-destructive">
Заполните имя и значения всех записей
</span>
) : (
saveMutation.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{saveMutation.error.message}
</span>
)
)}
<div className="ml-auto flex items-center gap-2">
{editingId && (
<Button type="button" variant="ghost" onClick={onCancelEdit}>
<X className="size-4" strokeWidth={1.75} />
Отменить
</Button>
)}
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Save className="size-4" strokeWidth={1.75} />
)}
Сохранить шаблон
</Button>
</div>
</div>
</form>
{deleteTemplate.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{deleteTemplate.error.message}
</span>
)}
{templateList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Шаблонов пока нет создайте первый выше.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Записей</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templateList.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-dns">{t.name}</TableCell>
<TableCell>{t.records.length}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label={`Редактировать ${t.name}`}
onClick={() => onEdit(t)}
>
<Pencil className="size-3.5" strokeWidth={1.75} />
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить шаблон ${t.name}`}
onClick={() => onDelete(t.id, t.name)}
disabled={deleteTemplate.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom"
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": { "@/*": ["./src/*"] },
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "vitest/globals"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"paths": { "@/*": ["./src/*"] }
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
},
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test-setup.ts",
},
})