initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

Binary file not shown.
+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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2488
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.48.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
Database,
Globe,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
Network,
PanelLeftClose,
ScrollText,
Settings,
Users,
X,
} from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { logout } from '../../lib/api.ts'
import type { Health } from '../../lib/types.ts'
import { HealthPill } from './HealthPill.tsx'
import { ThemeToggle } from './ThemeToggle.tsx'
import { Tooltip } from '../ui/Tooltip.tsx'
interface NavItem {
to: string
label: string
icon: LucideIcon
end?: boolean
}
const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText },
{ to: '/cache', label: 'Cache', icon: Database },
{ to: '/settings', label: 'Settings', icon: Settings },
]
const MOBILE_QUERY = '(max-width: 1023px)'
export function AppShell({
children,
health,
nodeId,
}: {
children: ReactNode
health?: Health
nodeId?: string
}) {
const [collapsed, setCollapsed] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [signingOut, setSigningOut] = useState(false)
const location = useLocation()
const navigate = useNavigate()
useEffect(() => {
const query = window.matchMedia(MOBILE_QUERY)
const sync = (): void => {
setIsMobile(query.matches)
if (!query.matches) setSheetOpen(false)
}
sync()
query.addEventListener('change', sync)
return () => query.removeEventListener('change', sync)
}, [])
// Any navigation closes the mobile sheet.
useEffect(() => {
setSheetOpen(false)
}, [location.pathname])
useEffect(() => {
if (!sheetOpen) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setSheetOpen(false)
}
document.addEventListener('keydown', onKey)
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
}
}, [sheetOpen])
const onSignOut = useCallback(() => {
setSigningOut(true)
logout()
.catch(() => {
/* the token is cleared either way */
})
.finally(() => {
setSigningOut(false)
navigate('/auth/login', { replace: true })
})
}, [navigate])
const rail = !isMobile && collapsed
return (
<div className="min-h-svh bg-canvas">
{/* Desktop rail toggle lives in the top bar; on mobile the bar carries
the hamburger that opens the sheet instead. */}
<header className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-2 border-b border-line bg-surface-2 px-3">
{isMobile ? (
<button
type="button"
aria-label="Open navigation"
onClick={() => setSheetOpen(true)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<Menu size={18} strokeWidth={2} aria-hidden="true" />
</button>
) : (
<button
type="button"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => setCollapsed((value) => !value)}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<PanelLeftClose
size={18}
strokeWidth={2}
aria-hidden="true"
className={cn('transition-transform', collapsed && 'rotate-180')}
/>
</button>
)}
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-accent-soft text-accent">
<Globe size={15} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="truncate text-sm font-semibold tracking-tight text-fg">fancy-dns</span>
</div>
<div className="ml-auto flex items-center gap-2">
{nodeId !== undefined && nodeId !== '' && (
<span
title={`Node ${nodeId}`}
className="tabular hidden max-w-40 truncate rounded-md border border-line bg-surface px-2 py-1 font-mono text-xs text-fg-muted sm:inline-block"
>
{nodeId}
</span>
)}
{health !== undefined && <HealthPill health={health} />}
<ThemeToggle />
<button
type="button"
aria-label="Sign out"
title="Sign out"
disabled={signingOut}
onClick={onSignOut}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg disabled:opacity-50"
>
<LogOut size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</header>
{/* Desktop sidebar. */}
<aside
className={cn(
'fixed top-14 bottom-0 left-0 z-30 hidden shrink-0 border-r border-line bg-surface transition-[width] duration-200 lg:flex lg:flex-col',
rail ? 'w-16' : 'w-56',
)}
>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
rail && 'justify-center px-0',
)
}
title={rail ? item.label : undefined}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
{!rail && <span className="truncate">{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
{/* Mobile sheet. */}
{sheetOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="absolute inset-0 bg-black/60"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Navigation"
className="absolute inset-y-0 left-0 flex w-64 flex-col border-r border-line bg-surface shadow-panel"
>
<div className="flex h-14 items-center justify-between border-b border-line px-3">
<span className="text-sm font-semibold text-fg">fancy-dns</span>
<button
type="button"
aria-label="Close navigation"
onClick={() => setSheetOpen(false)}
className="inline-flex size-8 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={() => setSheetOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent-soft text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
)
}
>
<item.icon size={16} strokeWidth={2} aria-hidden="true" />
<span className="truncate">{item.label}</span>
</NavLink>
))}
</nav>
</aside>
</div>
)}
<main
className={cn(
'pt-14 transition-[padding] duration-200',
isMobile ? 'px-3 py-4' : rail ? 'pl-16' : 'pl-56',
)}
>
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-4 p-3 sm:p-4 lg:p-6">
{children}
</div>
</main>
</div>
)
}
/** Re-exported so pages can label collapsed rail items without importing ui. */
export { Tooltip }
+32
View File
@@ -0,0 +1,32 @@
import type { Health } from '../../lib/types.ts'
import { cn } from '../../lib/cn.ts'
const STYLES: Record<Health, { dot: string; text: string; ring: string }> = {
ok: { dot: 'bg-ok', text: 'text-ok', ring: 'border-ok/30 bg-ok-soft' },
degraded: { dot: 'bg-warn', text: 'text-warn', ring: 'border-warn/30 bg-warn-soft' },
down: { dot: 'bg-danger', text: 'text-danger', ring: 'border-danger/30 bg-danger-soft' },
}
const DEFAULT_LABEL: Record<Health, string> = {
ok: 'Healthy',
degraded: 'Degraded',
down: 'Down',
}
export function HealthPill({ health, label, className }: { health: Health; label?: string; className?: string }) {
const style = STYLES[health] ?? STYLES.degraded
return (
<span
title={`Node health: ${health}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium whitespace-nowrap',
style.ring,
style.text,
className,
)}
>
<span className={cn('size-1.5 rounded-full', style.dot, health === 'down' && 'animate-pulse-soft')} />
{label ?? DEFAULT_LABEL[health] ?? health}
</span>
)
}
@@ -0,0 +1,18 @@
import { Skeleton } from '../ui/Skeleton.tsx'
import { cn } from '../../lib/cn.ts'
export function LoadingBlock({ rows = 5, className }: { rows?: number; className?: string }) {
const count = Math.max(1, Math.min(rows, 50))
return (
<div className={cn('flex w-full flex-col gap-2', className)} aria-busy="true" aria-live="polite">
<span className="sr-only">Loading…</span>
{Array.from({ length: count }, (_, index) => (
<div key={index} className="flex items-center gap-3">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '../ui/Button.tsx'
import { cn } from '../../lib/cn.ts'
export function PageError({
error,
onRetry,
className,
}: {
error: string
onRetry?: () => void
className?: string
}) {
return (
<div
role="alert"
className={cn(
'flex flex-col items-start gap-3 rounded-xl border border-danger/30 bg-danger-soft p-4 sm:flex-row sm:items-center sm:justify-between',
className,
)}
>
<div className="flex min-w-0 items-start gap-2.5">
<AlertTriangle size={18} strokeWidth={2} className="mt-0.5 shrink-0 text-danger" aria-hidden="true" />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">Could not load this view</p>
<p className="mt-0.5 text-sm break-words text-fg-muted">{error}</p>
</div>
</div>
{onRetry && (
<Button variant="outline" icon={RefreshCw} onClick={onRetry} className="shrink-0">
Retry
</Button>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function PageHeader({
title,
description,
actions,
className,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}) {
return (
<header className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-fg">{title}</h1>
{description !== undefined && (
<p className="mt-0.5 text-sm text-fg-muted">{description}</p>
)}
</div>
{actions !== undefined && (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
)}
</header>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export type Theme = 'dark' | 'light'
const STORAGE_KEY = 'fancydns.theme'
function readStoredTheme(): Theme {
try {
const value = localStorage.getItem(STORAGE_KEY)
if (value === 'light' || value === 'dark') return value
} catch {
/* storage disabled: fall through to the default */
}
return 'dark'
}
/**
* Runs at module evaluation, i.e. while the bundle is still being parsed and
* before the first paint, so the console never flashes the wrong theme.
*/
const INITIAL_THEME: Theme = (() => {
if (typeof document === 'undefined') return 'dark'
const theme = readStoredTheme()
document.documentElement.dataset.theme = theme
return theme
})()
export function ThemeToggle({ className }: { className?: string }) {
const [theme, setTheme] = useState<Theme>(INITIAL_THEME)
// A second tab changing the theme should not desync this one.
useEffect(() => {
const onStorage = (event: StorageEvent): void => {
if (event.key !== STORAGE_KEY) return
const next = readStoredTheme()
document.documentElement.dataset.theme = next
setTheme(next)
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const apply = (next: Theme): void => {
document.documentElement.dataset.theme = next
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
/* not fatal: the choice just does not survive a reload */
}
setTheme(next)
}
const next: Theme = theme === 'dark' ? 'light' : 'dark'
return (
<button
type="button"
onClick={() => apply(next)}
title={`Switch to ${next} theme`}
aria-label={`Switch to ${next} theme`}
className={cn(
'inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg',
className,
)}
>
{theme === 'dark' ? (
<Moon size={16} strokeWidth={2} aria-hidden="true" />
) : (
<Sun size={16} strokeWidth={2} aria-hidden="true" />
)}
</button>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** The shell and the page-level helpers. */
export { AppShell } from './AppShell.tsx'
export { PageHeader } from './PageHeader.tsx'
export { ThemeToggle } from './ThemeToggle.tsx'
export { HealthPill } from './HealthPill.tsx'
export { PageError } from './PageError.tsx'
export { LoadingBlock } from './LoadingBlock.tsx'
export type { Theme } from './ThemeToggle.tsx'
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export type BadgeTone = 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted'
export const BADGE_TONES: Record<BadgeTone, string> = {
default: 'border-line bg-surface-3 text-fg',
ok: 'border-transparent bg-ok-soft text-ok',
warn: 'border-transparent bg-warn-soft text-warn',
danger: 'border-transparent bg-danger-soft text-danger',
info: 'border-transparent bg-info-soft text-info',
muted: 'border-transparent bg-surface-3 text-fg-subtle',
}
export function Badge({
tone = 'default',
children,
className,
}: {
tone?: BadgeTone
children: ReactNode
className?: string
}) {
return (
<span
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs font-medium whitespace-nowrap',
BADGE_TONES[tone],
className,
)}
>
{children}
</span>
)
}
+72
View File
@@ -0,0 +1,72 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Spinner } from './Spinner.tsx'
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
export const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-accent-strong text-accent-fg hover:brightness-110 active:brightness-95',
secondary: 'bg-surface-3 text-fg hover:bg-line-strong border border-line',
ghost: 'text-fg-muted hover:bg-surface-3 hover:text-fg',
danger: 'bg-danger text-white hover:brightness-110 active:brightness-95',
outline: 'border border-line-strong text-fg hover:bg-surface-3',
}
export const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'h-8 gap-1.5 px-2.5 text-xs',
md: 'h-9 gap-2 px-3 text-sm',
lg: 'h-11 gap-2 px-5 text-sm',
icon: 'size-9 justify-center',
}
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
icon?: LucideIcon
iconRight?: LucideIcon
children?: ReactNode
}
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
icon: Icon,
iconRight: IconRight,
className,
children,
disabled,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
// A pending action must not be fireable twice: a double "Flush cache"
// is a support ticket.
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
'inline-flex shrink-0 items-center rounded-lg font-medium whitespace-nowrap transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
BUTTON_VARIANTS[variant],
BUTTON_SIZES[size],
className,
)}
{...props}
>
{loading ? (
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
) : (
Icon && <Icon size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
)}
{children}
{IconRight && !loading && (
<IconRight size={size === 'sm' ? 14 : 16} strokeWidth={2} aria-hidden="true" />
)}
</button>
)
}
+111
View File
@@ -0,0 +1,111 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Card({
title,
subtitle,
actions,
children,
className,
padded = true,
}: {
title?: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children?: ReactNode
className?: string
padded?: boolean
}) {
const hasHeader = title !== undefined || subtitle !== undefined || actions !== undefined
return (
<section
className={cn(
'flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-panel',
className,
)}
>
{hasHeader && (
<CardHeader actions={actions}>
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
{title === undefined && subtitle !== undefined && (
<p className="text-sm text-fg-muted">{subtitle}</p>
)}
</CardHeader>
)}
<CardBody padded={padded}>{children}</CardBody>
</section>
)
}
export function CardHeader({
title,
subtitle,
actions,
children,
className,
}: {
title?: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children?: ReactNode
className?: string
}) {
return (
<header
className={cn(
'flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface-2 px-4 py-3',
className,
)}
>
<div className="min-w-0">
{title !== undefined && <CardTitle subtitle={subtitle}>{title}</CardTitle>}
{children}
</div>
{actions !== undefined && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)
}
export function CardTitle({
children,
subtitle,
className,
}: {
children?: ReactNode
subtitle?: ReactNode
className?: string
}) {
return (
<div className={cn('min-w-0', className)}>
<h2 className="truncate text-sm font-semibold text-fg">{children}</h2>
{subtitle !== undefined && <p className="mt-0.5 text-xs text-fg-muted">{subtitle}</p>}
</div>
)
}
export function CardBody({
children,
className,
padded = true,
}: {
children?: ReactNode
className?: string
padded?: boolean
}) {
return (
<div className={cn('flex-1 min-w-0', padded && 'p-4', className)}>{children}</div>
)
}
export function CardFooter({ children, className }: { children?: ReactNode; className?: string }) {
return (
<footer
className={cn(
'flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3',
className,
)}
>
{children}
</footer>
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { Button } from './Button.tsx'
import { Dialog } from './Dialog.tsx'
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Confirm',
destructive = false,
pending = false,
}: {
open: boolean
onClose: () => void
onConfirm: () => void | Promise<unknown>
title: ReactNode
message: ReactNode
confirmLabel?: string
destructive?: boolean
pending?: boolean
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={pending}>
Cancel
</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
onClick={() => void onConfirm()}
loading={pending}
>
{confirmLabel}
</Button>
</>
}
>
<p className="text-fg-muted">{message}</p>
</Dialog>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { IconButton } from './IconButton.tsx'
/** Copies a value (a rule, an address, a key) to the clipboard. */
export function CopyButton({
value,
label = 'Copy',
className,
}: {
value: string
label?: string
className?: string
}) {
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => {
return () => {
if (timer.current !== undefined) clearTimeout(timer.current)
}
}, [])
const copy = (): void => {
const clipboard = navigator?.clipboard
if (!clipboard) return
void clipboard.writeText(value).then(
() => {
setCopied(true)
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 1500)
},
() => {
// A denied clipboard permission is not worth a toast; the button just
// does not flip.
},
)
}
return (
<IconButton
icon={copied ? Check : Copy}
label={copied ? 'Copied' : label}
variant="ghost"
size="sm"
onClick={copy}
className={cn(copied && 'text-ok', className)}
/>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useId, useRef } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { IconButton } from './IconButton.tsx'
const SIZES = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const
/**
* Native `<dialog>`: focus trapping, inert background and Escape handling come
* from the platform instead of a focus-trap library.
*/
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
}: {
open: boolean
onClose: () => void
title: ReactNode
description?: ReactNode
children?: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}) {
const ref = useRef<HTMLDialogElement>(null)
const titleId = useId()
const descId = useId()
useEffect(() => {
const el = ref.current
if (!el) return
if (open && !el.open) {
if (typeof el.showModal === 'function') el.showModal()
else el.setAttribute('open', '')
} else if (!open && el.open) {
el.close()
}
}, [open])
// The element can be removed while open (route change): close it so the
// top layer does not keep a detached node.
useEffect(() => {
const el = ref.current
return () => {
if (el?.open) el.close()
}
}, [])
const onBackdropClick = (event: MouseEvent<HTMLDialogElement>): void => {
// Only a click on the dialog element itself is a backdrop click; clicks
// inside the panel bubble up with a different target.
if (event.target === ref.current) onClose()
}
return (
<dialog
ref={ref}
aria-labelledby={titleId}
aria-describedby={description !== undefined ? descId : undefined}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClose={onClose}
onClick={onBackdropClick}
className={cn(
'm-auto w-[calc(100vw-2rem)] rounded-xl border border-line bg-surface p-0 text-fg shadow-panel backdrop:bg-black/60',
'open:animate-slide-up',
SIZES[size],
)}
>
<div className="flex items-start justify-between gap-3 border-b border-line px-4 py-3">
<div className="min-w-0">
<h2 id={titleId} className="text-sm font-semibold">
{title}
</h2>
{description !== undefined && (
<p id={descId} className="mt-0.5 text-xs text-fg-muted">
{description}
</p>
)}
</div>
<IconButton icon={X} label="Close" onClick={onClose} />
</div>
<div className="max-h-[70vh] overflow-auto px-4 py-4 text-sm">{children}</div>
{footer !== undefined && (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-4 py-3">
{footer}
</div>
)}
</dialog>
)
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Inbox } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
className,
}: {
icon?: LucideIcon
title: ReactNode
description?: ReactNode
action?: ReactNode
className?: string
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-line px-6 py-12 text-center',
className,
)}
>
<span className="grid size-11 place-items-center rounded-full bg-surface-3 text-fg-subtle">
<Icon size={20} strokeWidth={1.75} aria-hidden="true" />
</span>
<div className="max-w-md">
<p className="text-sm font-medium text-fg">{title}</p>
{description !== undefined && (
<p className="mt-1 text-sm text-fg-muted">{description}</p>
)}
</div>
{action !== undefined && <div className="mt-1 flex items-center gap-2">{action}</div>}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { BUTTON_SIZES, BUTTON_VARIANTS } from './Button.tsx'
import type { ButtonSize, ButtonVariant } from './Button.tsx'
import { Tooltip } from './Tooltip.tsx'
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
icon: LucideIcon
label: string
variant?: ButtonVariant
size?: ButtonSize
tooltipSide?: 'top' | 'bottom' | 'left' | 'right'
}
export function IconButton({
icon: Icon,
label,
variant = 'ghost',
size = 'icon',
tooltipSide = 'top',
className,
type = 'button',
...props
}: IconButtonProps) {
return (
<Tooltip content={label} side={tooltipSide}>
<button
type={type}
aria-label={label}
title={label}
className={cn(
'inline-flex shrink-0 items-center rounded-lg transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
BUTTON_VARIANTS[variant],
BUTTON_SIZES[size],
className,
)}
{...props}
>
<Icon size={16} strokeWidth={2} aria-hidden="true" />
</button>
</Tooltip>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useId } from 'react'
import type { InputHTMLAttributes, ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export const FIELD_BASE =
'w-full rounded-lg border bg-surface-2 text-fg placeholder:text-fg-subtle transition-colors focus:outline-none focus-visible:border-accent disabled:opacity-60'
export function FieldFrame({
id,
label,
hint,
error,
children,
}: {
id: string
label?: ReactNode
hint?: ReactNode
error?: ReactNode
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
{label !== undefined && (
<label htmlFor={id} className="text-xs font-medium text-fg-muted">
{label}
</label>
)}
{children}
{error !== undefined && error !== null && error !== '' ? (
<p id={`${id}-error`} role="alert" className="text-xs text-danger">
{error}
</p>
) : hint !== undefined ? (
<p id={`${id}-hint`} className="text-xs text-fg-subtle">
{hint}
</p>
) : null}
</div>
)
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
icon?: LucideIcon
className?: string
}
export function Input({ label, hint, error, icon: Icon, className, id, ...props }: InputProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint !== undefined ? `${inputId}-hint` : undefined
const input = (
<div className="relative">
{Icon && (
<Icon
size={15}
strokeWidth={2}
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-fg-subtle"
/>
)}
<input
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'h-9 px-2.5 text-sm',
Icon && 'pl-8',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
/>
</div>
)
if (label === undefined && hint === undefined && error === undefined) return input
return (
<FieldFrame id={inputId} label={label} hint={hint} error={error}>
{input}
</FieldFrame>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Kbd({ children, className }: { children: ReactNode; className?: string }) {
return (
<kbd
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border border-line-strong bg-surface-3 px-1.5 font-mono text-[10px] font-medium text-fg-muted',
className,
)}
>
{children}
</kbd>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Button } from './Button.tsx'
export function Pagination({
page,
perPage,
total,
onPage,
className,
}: {
page: number
perPage: number
total: number
onPage: (page: number) => void
className?: string
}) {
const size = perPage > 0 ? perPage : 1
const pages = Math.max(1, Math.ceil(total / size))
const current = Math.min(Math.max(1, page), pages)
const first = (current - 1) * size + 1
const last = Math.min(total, current * size)
return (
<div
className={cn(
'flex flex-wrap items-center justify-between gap-3 text-xs text-fg-muted',
className,
)}
>
<p className="tabular">
{total === 0 ? 'no rows' : `${first}–${last} of ${total.toLocaleString('en-US')}`}
</p>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
icon={ChevronLeft}
disabled={current <= 1}
onClick={() => onPage(current - 1)}
aria-label="Previous page"
>
Prev
</Button>
<span className="tabular px-1">
{current} / {pages}
</span>
<Button
variant="outline"
size="sm"
iconRight={ChevronRight}
disabled={current >= pages}
onClick={() => onPage(current + 1)}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { cn } from '../../lib/cn.ts'
export type ProgressTone = 'default' | 'ok' | 'warn' | 'danger' | 'info'
const TONES: Record<ProgressTone, string> = {
default: 'bg-accent',
ok: 'bg-ok',
warn: 'bg-warn',
danger: 'bg-danger',
info: 'bg-info',
}
export function Progress({
value,
max = 100,
tone = 'default',
className,
showLabel = false,
}: {
value: number
max?: number
tone?: ProgressTone
className?: string
showLabel?: boolean
}) {
const safeMax = Number.isFinite(max) && max > 0 ? max : 100
const safeValue = Number.isFinite(value) ? value : 0
const pct = Math.max(0, Math.min(100, (safeValue / safeMax) * 100))
return (
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
<div
role="progressbar"
aria-valuenow={Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full min-w-0 overflow-hidden rounded-full bg-surface-3"
>
<div
className={cn('h-full rounded-full transition-[width] duration-300', TONES[tone])}
style={{ width: `${pct}%` }}
/>
</div>
{showLabel && (
<span className="tabular w-9 shrink-0 text-right text-xs text-fg-muted">
{pct.toFixed(0)}%
</span>
)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function SectionTitle({
title,
description,
actions,
className,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-wrap items-end justify-between gap-3', className)}>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
{description !== undefined && (
<p className="mt-0.5 text-xs text-fg-muted">{description}</p>
)}
</div>
{actions !== undefined && (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
)}
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useId } from 'react'
import type { ReactNode, SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { FIELD_BASE, FieldFrame } from './Input.tsx'
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
options: SelectOption[]
className?: string
selectClassName?: string
}
export function Select({
label,
hint,
error,
options,
className,
selectClassName,
id,
...props
}: SelectProps) {
const autoId = useId()
const selectId = id ?? autoId
const describedBy = error ? `${selectId}-error` : hint !== undefined ? `${selectId}-hint` : undefined
const select = (
<div className="relative">
<select
id={selectId}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'h-9 appearance-none py-0 pr-8 pl-2.5 text-sm',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown
size={15}
strokeWidth={2}
aria-hidden="true"
className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-fg-subtle"
/>
</div>
)
if (label === undefined && hint === undefined && error === undefined) return select
return (
<FieldFrame id={selectId} label={label} hint={hint} error={error}>
{select}
</FieldFrame>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from '../../lib/cn.ts'
/** A shimmer placeholder used while a panel's data is in flight. */
export function Skeleton({ className }: { className?: string }) {
return (
<div
aria-hidden="true"
className={cn(
'relative overflow-hidden rounded-md bg-surface-3',
'after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/8 after:to-transparent',
className,
)}
/>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { cn } from '../../lib/cn.ts'
const SIZES = {
sm: 'size-3 border',
md: 'size-4 border-2',
lg: 'size-6 border-2',
} as const
export function Spinner({ size = 'md', className }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
return (
<span
role="status"
aria-live="polite"
aria-label="Loading"
className={cn(
'inline-block shrink-0 animate-spin rounded-full border-current border-t-transparent opacity-70',
SIZES[size],
className,
)}
/>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
import { Skeleton } from './Skeleton.tsx'
export type StatTone = 'default' | 'ok' | 'warn' | 'danger'
const TONE_TEXT: Record<StatTone, string> = {
default: 'text-fg',
ok: 'text-ok',
warn: 'text-warn',
danger: 'text-danger',
}
const TONE_ICON: Record<StatTone, string> = {
default: 'bg-surface-3 text-fg-muted',
ok: 'bg-ok-soft text-ok',
warn: 'bg-warn-soft text-warn',
danger: 'bg-danger-soft text-danger',
}
export function StatCard({
label,
value,
hint,
icon: Icon,
trend,
tone = 'default',
className,
loading,
}: {
label: ReactNode
value: ReactNode
hint?: ReactNode
icon?: LucideIcon
trend?: number
tone?: StatTone
className?: string
loading?: boolean
}) {
const TrendIcon = trend === undefined || trend === 0 ? Minus : trend > 0 ? ArrowUpRight : ArrowDownRight
// A rising block rate is good news; a rising error rate is not. The tone of
// the card wins, otherwise fall back to "up is good".
const trendTone = trend === undefined || trend === 0 ? 'text-fg-subtle' : (trend > 0) === (tone !== 'danger') ? 'text-ok' : 'text-danger'
return (
<div
className={cn(
'flex min-w-0 items-start gap-3 rounded-xl border border-line bg-surface p-4 shadow-panel',
className,
)}
>
{Icon && (
<span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', TONE_ICON[tone])}>
<Icon size={18} strokeWidth={2} aria-hidden="true" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium tracking-wide text-fg-muted uppercase">{label}</p>
{loading ? (
<Skeleton className="mt-2 h-7 w-24" />
) : (
<p className={cn('tabular mt-1 truncate text-2xl font-semibold', TONE_TEXT[tone])}>{value}</p>
)}
{(hint !== undefined || trend !== undefined) && (
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs">
{trend !== undefined && (
<span className={cn('tabular inline-flex items-center gap-0.5 font-medium', trendTone)}>
<TrendIcon size={14} aria-hidden="true" />
{Math.abs(trend).toFixed(1)}%
</span>
)}
{hint !== undefined && <span className="truncate text-fg-subtle">{hint}</span>}
</div>
)}
</div>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export function Switch({
checked,
onChange,
label,
description,
disabled = false,
className,
}: {
checked: boolean
onChange: (checked: boolean) => void
label?: ReactNode
description?: ReactNode
disabled?: boolean
className?: string
}) {
const control = (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={typeof label === 'string' ? label : undefined}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
checked ? 'border-accent-strong bg-accent-strong' : 'border-line-strong bg-surface-3',
className,
)}
>
<span
aria-hidden="true"
className={cn(
'absolute size-3.5 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
)}
/>
</button>
)
if (label === undefined && description === undefined) return control
return (
<label
className={cn(
'flex items-start justify-between gap-3',
disabled ? 'opacity-60' : 'cursor-pointer',
)}
>
<span className="flex min-w-0 flex-col gap-0.5">
{label !== undefined && <span className="text-sm text-fg">{label}</span>}
{description !== undefined && (
<span className="text-xs text-fg-muted">{description}</span>
)}
</span>
<span onClick={(e) => e.preventDefault()} className="mt-0.5 shrink-0">
{control}
</span>
</label>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
export type TableAlign = 'left' | 'right' | 'center'
export interface TableColumn {
key: string
header: ReactNode
align?: TableAlign
width?: string
className?: string
}
const ALIGN: Record<TableAlign, string> = {
left: 'text-left',
right: 'text-right',
center: 'text-center',
}
/**
* A plain data table: the caller owns the cell contents, so a cell is either a
* ready-made node (a Badge, a switch) or a raw value it can format.
*/
export function Table({
columns,
rows,
rowKey,
empty = 'Nothing to show',
loading = false,
onRowClick,
maxHeight,
className,
}: {
columns: TableColumn[]
rows: Record<string, unknown>[]
rowKey: (row: Record<string, unknown>) => string
empty?: ReactNode
loading?: boolean
onRowClick?: (row: Record<string, unknown>) => void
maxHeight?: number | string
className?: string
}) {
const [hovered, setHovered] = useState<string | null>(null)
const maxHeightStyle =
typeof maxHeight === 'number' ? { maxHeight: `${maxHeight}px` } : maxHeight ? { maxHeight } : undefined
if (rows.length === 0 && !loading) {
return (
<div
className={cn(
'flex items-center justify-center rounded-lg border border-dashed border-line p-8 text-sm text-fg-subtle',
className,
)}
>
{empty}
</div>
)
}
return (
<div
className={cn('relative overflow-auto rounded-lg border border-line', className)}
style={maxHeightStyle}
>
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-surface-2">
<tr className="border-b border-line">
{columns.map((column) => (
<th
key={column.key}
scope="col"
style={column.width ? { width: column.width } : undefined}
className={cn(
'px-3 py-2 text-xs font-semibold tracking-wide text-fg-muted uppercase',
ALIGN[column.align ?? 'left'],
column.className,
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const key = rowKey(row)
const clickable = onRowClick !== undefined
return (
<tr
key={key}
onClick={clickable ? () => onRowClick(row) : undefined}
onMouseEnter={clickable ? () => setHovered(key) : undefined}
onMouseLeave={clickable ? () => setHovered(null) : undefined}
className={cn(
'border-b border-line last:border-0',
clickable && 'cursor-pointer',
hovered === key ? 'bg-surface-3' : 'hover:bg-surface-2',
)}
>
{columns.map((column) => {
const value = row[column.key]
return (
<td
key={column.key}
className={cn(
'px-3 py-2 align-middle text-fg',
ALIGN[column.align ?? 'left'],
column.align === 'right' && 'tabular',
column.className,
)}
>
{value === null || value === undefined || value === ''
? '—'
: isRenderable(value)
? value
: String(value)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
{loading && (
<div className="absolute inset-0 grid place-items-center bg-surface/60 backdrop-blur-[1px]">
<span className="rounded-md border border-line bg-surface-2 px-2 py-1 text-xs text-fg-muted">
Loading…
</span>
</div>
)}
</div>
)
}
/** React elements and arrays render as-is; a plain object does not. */
function isRenderable(value: unknown): value is ReactNode {
if (Array.isArray(value)) return true
if (typeof value === 'object' && value !== null) {
return typeof (value as { $$typeof?: unknown }).$$typeof === 'symbol'
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
import type { LucideIcon } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export interface TabItem {
id: string
label: string
count?: number
icon?: LucideIcon
}
export function Tabs({
tabs,
value,
onChange,
className,
}: {
tabs: TabItem[]
value: string
onChange: (id: string) => void
className?: string
}) {
return (
<div
role="tablist"
className={cn(
'inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-line bg-surface-2 p-1',
className,
)}
>
{tabs.map((tab) => {
const active = tab.id === value
const Icon = tab.icon
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.id)}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
)}
>
{Icon && <Icon size={14} strokeWidth={2} aria-hidden="true" />}
{tab.label}
{tab.count !== undefined && (
<span
className={cn(
'tabular rounded px-1 py-px text-[10px]',
active ? 'bg-accent-soft text-accent' : 'bg-surface-3 text-fg-subtle',
)}
>
{tab.count}
</span>
)}
</button>
)
})}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useId } from 'react'
import type { ReactNode, TextareaHTMLAttributes } from 'react'
import { cn } from '../../lib/cn.ts'
import { FIELD_BASE, FieldFrame } from './Input.tsx'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
monospace?: boolean
className?: string
}
export function Textarea({
label,
hint,
error,
monospace = false,
className,
id,
rows = 6,
...props
}: TextareaProps) {
const autoId = useId()
const fieldId = id ?? autoId
const describedBy = error ? `${fieldId}-error` : hint !== undefined ? `${fieldId}-hint` : undefined
const textarea = (
<textarea
id={fieldId}
rows={rows}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
FIELD_BASE,
'resize-y px-2.5 py-2 text-sm leading-relaxed',
monospace && 'font-mono text-xs',
error ? 'border-danger' : 'border-line',
className,
)}
{...props}
/>
)
if (label === undefined && hint === undefined && error === undefined) return textarea
return (
<FieldFrame id={fieldId} label={label} hint={hint} error={error}>
{textarea}
</FieldFrame>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react'
import { cn } from '../../lib/cn.ts'
export type ToastTone = 'default' | 'ok' | 'warn' | 'danger'
export interface ToastOptions {
title: string
description?: string
tone?: ToastTone
durationMs?: number
}
interface ToastItem extends ToastOptions {
id: number
tone: ToastTone
durationMs: number
}
interface ToastContextValue {
toasts: ToastItem[]
toast: (options: ToastOptions) => void
dismiss: (id: number) => void
claimToaster: () => boolean
}
const ToastContext = createContext<ToastContextValue | null>(null)
const TONE_STYLES: Record<ToastTone, string> = {
default: 'border-line bg-surface-3',
ok: 'border-ok/40 bg-ok-soft',
warn: 'border-warn/40 bg-warn-soft',
danger: 'border-danger/40 bg-danger-soft',
}
const TONE_ICON: Record<ToastTone, typeof Info> = {
default: Info,
ok: CheckCircle2,
warn: AlertTriangle,
danger: XCircle,
}
let nextId = 1
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
const claimed = useRef(false)
useEffect(() => {
const pending = timers.current
return () => {
for (const timer of pending.values()) clearTimeout(timer)
pending.clear()
}
}, [])
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timers.current.delete(id)
}
setToasts((current) => current.filter((item) => item.id !== id))
}, [])
const toast = useCallback(
(options: ToastOptions) => {
const item: ToastItem = {
tone: 'default',
durationMs: 5000,
...options,
id: nextId++,
}
// Cap the stack: a failing refresh loop must not bury the screen.
setToasts((current) => [...current, item].slice(-5))
if (item.durationMs > 0) {
timers.current.set(item.id, setTimeout(() => dismiss(item.id), item.durationMs))
}
},
[dismiss],
)
const claimToaster = useCallback(() => {
if (claimed.current) return false
claimed.current = true
return true
}, [])
const value = useMemo<ToastContextValue>(
() => ({ toasts, toast, dismiss, claimToaster }),
[toasts, toast, dismiss, claimToaster],
)
return (
<ToastContext.Provider value={value}>
{children}
<Toaster />
</ToastContext.Provider>
)
}
const NOOP = {
toast: () => {},
dismiss: () => {},
}
/**
* Fires a toast. Outside a provider it degrades to a no-op instead of
* throwing: a notification is never a reason for a page to fail to render.
*/
export function useToast(): { toast: (options: ToastOptions) => void } {
const ctx = useContext(ToastContext)
if (ctx === null) return NOOP
return { toast: ctx.toast }
}
/**
* Renders the stack bottom-right in a portal on <body>. The provider already
* mounts one; a hand-placed extra <Toaster/> is ignored so the DOM never
* doubles up.
*/
export function Toaster() {
const ctx = useContext(ToastContext)
const [owns] = useState(() => (ctx === null ? false : ctx.claimToaster()))
if (ctx === null || !owns || ctx.toasts.length === 0) return null
if (typeof document === 'undefined') return null
return createPortal(
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed right-4 bottom-4 z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
>
{ctx.toasts.map((item) => {
const Icon = TONE_ICON[item.tone]
return (
<div
key={item.id}
className={cn(
'animate-slide-up pointer-events-auto flex items-start gap-2.5 rounded-lg border p-3 shadow-panel',
TONE_STYLES[item.tone],
)}
>
<Icon
size={16}
strokeWidth={2}
className="mt-0.5 shrink-0 text-fg-muted"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">{item.title}</p>
{item.description !== undefined && (
<p className="mt-0.5 text-xs break-words text-fg-muted">{item.description}</p>
)}
</div>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => ctx.dismiss(item.id)}
className="shrink-0 rounded-md p-0.5 text-fg-subtle transition-colors hover:bg-surface-3 hover:text-fg"
>
<X size={14} strokeWidth={2} aria-hidden="true" />
</button>
</div>
)
})}
</div>,
document.body,
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import { cn } from '../../lib/cn.ts'
const SIDES = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-1.5',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-1.5',
left: 'right-full top-1/2 -translate-y-1/2 mr-1.5',
right: 'left-full top-1/2 -translate-y-1/2 ml-1.5',
} as const
/**
* Hover/focus tooltip built from one wrapper element. No positioning library
* and no portal: the console has no tooltip that needs to escape an
* overflow container, and CSS keeps it working in a screenshot test.
*/
export function Tooltip({
content,
children,
side = 'top',
className,
}: {
content: ReactNode
children: ReactNode
side?: 'top' | 'bottom' | 'left' | 'right'
className?: string
}) {
if (content === undefined || content === null || content === '') return <>{children}</>
return (
<span className={cn('group/tt relative inline-flex', className)}>
{children}
<span
role="tooltip"
className={cn(
'pointer-events-none absolute z-50 hidden w-max max-w-64 rounded-md border border-line bg-surface-3 px-2 py-1 text-xs font-normal text-fg shadow-panel',
'animate-fade-in group-hover/tt:block group-focus-within/tt:block',
SIDES[side],
)}
>
{content}
</span>
</span>
)
}
+36
View File
@@ -0,0 +1,36 @@
/** Every primitive the pages are allowed to reach for. */
export { Card, CardBody, CardFooter, CardHeader, CardTitle } from './Card.tsx'
export { StatCard } from './StatCard.tsx'
export { Button } from './Button.tsx'
export { IconButton } from './IconButton.tsx'
export { Input } from './Input.tsx'
export { Textarea } from './Textarea.tsx'
export { Select } from './Select.tsx'
export { Switch } from './Switch.tsx'
export { Badge } from './Badge.tsx'
export { Table } from './Table.tsx'
export { Dialog } from './Dialog.tsx'
export { ConfirmDialog } from './ConfirmDialog.tsx'
export { Tooltip } from './Tooltip.tsx'
export { ToastProvider, Toaster, useToast } from './Toast.tsx'
export { Skeleton } from './Skeleton.tsx'
export { EmptyState } from './EmptyState.tsx'
export { Tabs } from './Tabs.tsx'
export { Spinner } from './Spinner.tsx'
export { Progress } from './Progress.tsx'
export { Pagination } from './Pagination.tsx'
export { Kbd } from './Kbd.tsx'
export { CopyButton } from './CopyButton.tsx'
export { SectionTitle } from './SectionTitle.tsx'
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.tsx'
export type { InputProps } from './Input.tsx'
export type { TextareaProps } from './Textarea.tsx'
export type { SelectOption, SelectProps } from './Select.tsx'
export type { TabItem } from './Tabs.tsx'
export type { TableColumn } from './Table.tsx'
export type { ToastOptions, ToastTone } from './Toast.tsx'
export type { BadgeTone } from './Badge.tsx'
export type { IconButtonProps } from './IconButton.tsx'
export type { StatTone } from './StatCard.tsx'
export type { ProgressTone } from './Progress.tsx'
+107
View File
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface ApiState<T> {
data: T | null
error: string | null
loading: boolean
refetch: () => void
}
function message(err: unknown): string {
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
return 'request failed'
}
/**
* Runs `fn` on mount and whenever `deps` change, aborting the previous request
* so a fast filter change cannot let a slow earlier response win a race.
*/
export function useApi<T>(fn: () => Promise<T>, deps: unknown[] = []): ApiState<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [nonce, setNonce] = useState(0)
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
const controller = new AbortController()
let live = true
setLoading(true)
fnRef
.current()
.then((value) => {
if (!live) return
setData(value)
setError(null)
})
.catch((err: unknown) => {
if (!live) return
if (err instanceof DOMException && err.name === 'AbortError') return
setError(message(err))
})
.finally(() => {
if (!live) return
setLoading(false)
})
return () => {
live = false
controller.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, nonce])
const refetch = useCallback(() => setNonce((n) => n + 1), [])
return { data, error, loading, refetch }
}
export interface AsyncAction<TArgs extends unknown[], T> {
run: (...args: TArgs) => Promise<T | null>
pending: boolean
error: string | null
clearError: () => void
}
/**
* A fire-and-forget mutation: `run` never rejects, it resolves to `null` on
* failure and puts the message in `error` so the caller can just toast it.
*/
export function useAsyncAction<TArgs extends unknown[], T>(
fn: (...args: TArgs) => Promise<T>,
): AsyncAction<TArgs, T> {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const mounted = useRef(true)
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const run = useCallback(async (...args: TArgs): Promise<T | null> => {
setPending(true)
setError(null)
try {
const result = await fnRef.current(...args)
return result
} catch (err) {
if (mounted.current) setError(message(err))
return null
} finally {
if (mounted.current) setPending(false)
}
}, [])
const clearError = useCallback(() => setError(null), [])
return { run, pending, error, clearError }
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
/**
* Polls `fn` every `intervalMs`, but only while the tab is visible: a hidden
* dashboard tab burning CPU on a node nobody is looking at is pure waste. The
* first tick after the tab becomes visible is immediate, so the numbers are
* never stale on return.
*/
export function useAutoRefresh(fn: () => void, intervalMs: number, enabled = true): void {
const fnRef = useRef(fn)
fnRef.current = fn
useEffect(() => {
if (!enabled || intervalMs <= 0) return
if (typeof document === 'undefined') return
let timer: ReturnType<typeof setInterval> | undefined
const start = (): void => {
if (timer !== undefined) return
if (document.hidden) {
// Hidden: no timer at all. The visibility listener restarts it.
return
}
timer = setInterval(() => {
if (!document.hidden) fnRef.current()
}, intervalMs)
}
const stop = (): void => {
if (timer !== undefined) {
clearInterval(timer)
timer = undefined
}
}
const onVisibility = (): void => {
if (document.hidden) {
stop()
return
}
start()
fnRef.current()
}
start()
document.addEventListener('visibilitychange', onVisibility)
return () => {
stop()
document.removeEventListener('visibilitychange', onVisibility)
}
}, [intervalMs, enabled])
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
/**
* Trails `value` by `ms`. Used by the search boxes so typing does not fire a
* request per keystroke.
*/
export function useDebounced<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
if (ms <= 0) {
setDebounced(value)
return
}
const timer = setTimeout(() => setDebounced(value), ms)
return () => clearTimeout(timer)
}, [value, ms])
return debounced
}
+44
View File
@@ -0,0 +1,44 @@
import { useState } from 'react'
export type TimeRange = '15m' | '1h' | '6h' | '24h'
const RANGE_MS: Record<TimeRange, number> = {
'15m': 15 * 60_000,
'1h': 60 * 60_000,
'6h': 6 * 60 * 60_000,
'24h': 24 * 60 * 60_000,
}
/** Roughly 40-60 bars per chart, which is where recharts stays readable. */
const RANGE_BUCKETS: Record<TimeRange, number> = {
'15m': 30,
'1h': 60,
'6h': 72,
'24h': 96,
}
export const TIME_RANGES: TimeRange[] = ['15m', '1h', '6h', '24h']
export interface TimeRangeState {
range: TimeRange
setRange: (range: TimeRange) => void
rangeMs: number
buckets: number
/** ISO timestamp for the `since` query parameter. */
since: () => string
}
export function useTimeRange(defaultRange: TimeRange = '1h'): TimeRangeState {
const [range, setRange] = useState<TimeRange>(defaultRange)
const rangeMs = RANGE_MS[range]
const buckets = RANGE_BUCKETS[range]
return {
range,
setRange,
rangeMs,
buckets,
since: () => new Date(Date.now() - rangeMs).toISOString(),
}
}
+246
View File
@@ -0,0 +1,246 @@
@import "tailwindcss";
/* Dark-first operator console. Every colour the UI uses comes from the custom
properties below, so a theme flip is a single attribute on <html> and never
a re-render. Tailwind utilities read them through `@theme inline`, which
keeps the var() reference in the generated CSS instead of freezing the
value at build time. */
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
:root {
color-scheme: dark;
/* Surfaces, from furthest back to closest to the reader. */
--canvas: #0a0c10;
--surface: #11141a;
--surface-2: #161a21;
--surface-3: #1d222b;
--line: #242a34;
--line-strong: #333b49;
/* Text. */
--fg: #e7eaef;
--fg-muted: #98a2b3;
--fg-subtle: #6b7687;
/* One accent: emerald. */
--accent: #34d399;
--accent-strong: #10b981;
--accent-fg: #04120c;
--accent-soft: rgb(52 211 153 / 14%);
--accent-ring: rgb(52 211 153 / 45%);
/* Semantics. */
--ok: #34d399;
--ok-soft: rgb(52 211 153 / 14%);
--warn: #fbbf24;
--warn-soft: rgb(251 191 36 / 14%);
--danger: #f87171;
--danger-soft: rgb(248 113 113 / 14%);
--info: #60a5fa;
--info-soft: rgb(96 165 250 / 14%);
--shadow-panel: 0 10px 30px -12px rgb(0 0 0 / 70%);
--scroll-thumb: #2b323d;
--scroll-thumb-hover: #3a4351;
}
[data-theme="light"] {
color-scheme: light;
--canvas: #f4f6f8;
--surface: #ffffff;
--surface-2: #ffffff;
--surface-3: #eef1f5;
--line: #e3e7ed;
--line-strong: #cbd2dc;
--fg: #0f141b;
--fg-muted: #55616f;
--fg-subtle: #7b8794;
--accent: #059669;
--accent-strong: #047857;
--accent-fg: #ffffff;
--accent-soft: rgb(5 150 105 / 10%);
--accent-ring: rgb(5 150 105 / 40%);
--ok: #059669;
--ok-soft: rgb(5 150 105 / 12%);
--warn: #b45309;
--warn-soft: rgb(180 83 9 / 12%);
--danger: #dc2626;
--danger-soft: rgb(220 38 38 / 10%);
--info: #2563eb;
--info-soft: rgb(37 99 235 / 10%);
--shadow-panel: 0 10px 30px -14px rgb(15 23 42 / 25%);
--scroll-thumb: #cbd2dc;
--scroll-thumb-hover: #aab3c0;
}
@theme inline {
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-accent-fg: var(--accent-fg);
--color-accent-soft: var(--accent-soft);
--color-ok: var(--ok);
--color-ok-soft: var(--ok-soft);
--color-warn: var(--warn);
--color-warn-soft: var(--warn-soft);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-info: var(--info);
--color-info-soft: var(--info-soft);
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
sans-serif;
--font-mono:
ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas,
"Liberation Mono", monospace;
--animate-fade-in: fade-in 180ms ease-out both;
--animate-slide-up: slide-up 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-pulse-soft: pulse-soft 2.4s ease-in-out infinite;
--animate-shimmer: shimmer 1.6s linear infinite;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes pulse-soft {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
}
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
background-color: var(--canvas);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fg);
}
:focus-visible {
outline: 2px solid var(--accent-ring);
outline-offset: 2px;
}
::selection {
background-color: var(--accent-soft);
color: var(--fg);
}
/* Numeric columns must not jitter as digits change. */
.tabular {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--scroll-thumb) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--scroll-thumb);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--scroll-thumb-hover);
}
::-webkit-scrollbar-corner {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
+546
View File
@@ -0,0 +1,546 @@
/**
* Typed client for the fancy-dns admin API.
*
* Every path is relative (`/api/...`) because the Go binary serves the built
* assets and the API from the same origin; in dev Vite proxies `/api` to the
* local daemon.
*/
import type {
BlocklistsResponse,
CacheResponse,
CacheTopResponse,
ClientsResponse,
ClientGroup,
Dashboard,
DefaultPolicy,
DomainAction,
DomainCheck,
DomainFilters,
DomainsResponse,
LogFilters,
LogItem,
LoginResponse,
LogsResponse,
LiveStats,
OkResponse,
SaveSettingsResponse,
SettingsResponse,
UpstreamBootstrap,
UpstreamPolicy,
UpstreamServer,
UpstreamsResponse,
ValidateResponse,
} from './types.ts'
const TOKEN_KEY = 'fancydns.token'
/** An HTTP-level failure. `body` is the raw response text for the logs. */
export class ApiError extends Error {
status: number
body: string
constructor(status: number, message: string, body = '') {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
export function getToken(): string {
try {
return localStorage.getItem(TOKEN_KEY) ?? ''
} catch {
// Private mode / disabled storage: behave as an anonymous session.
return ''
}
}
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token)
} catch {
/* non-fatal: the session just does not survive a reload */
}
}
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
/* ignore */
}
}
/** Types the API returns for endpoints the shared types.ts does not name. */
export interface WhoamiResponse {
name: string
mode: string
read_only: boolean
can_write: boolean
auth_mode: string
base_path: string
}
export interface LoginInput {
api_key?: string
username?: string
password?: string
}
export interface RefreshBlocklistsResponse {
started: boolean
names: string[]
}
export interface ToggleBlocklistResponse {
ok: boolean
name: string
enabled: boolean
}
export interface AddDomainInput {
name: string
action: DomainAction | string
list?: string
source?: string
content?: string
}
/** A lookup failure comes back 200 with an `error` string, not a 4xx. */
export type DomainCheckResponse = DomainCheck & { error?: string }
export interface ClientsPayload {
groups: ClientGroup[]
default_policy: DefaultPolicy | string
}
export interface UpstreamsPayload {
policy: UpstreamPolicy | string
servers: UpstreamServer[]
bootstraps: UpstreamBootstrap[]
healthcheck_interval_s?: number
max_attempts?: number
breaker_threshold?: number
}
export interface SaveResponse {
ok: boolean
version: string
warnings?: string[]
}
export interface FlushCacheResponse {
ok: boolean
entries: number
}
export interface ReloadSettingsResponse {
ok: boolean
version: string
}
export type LogStreamHandlers = {
onEntry: (entry: LogItem) => void
onStats: (stats: LiveStats) => void
onReady: (info: { at?: string; version?: string }) => void
onError: (message: string) => void
onOpen: () => void
}
/** Query values the server should not see at all. */
type ParamValue = string | number | boolean | null | undefined
/** Builds `/api/thing?a=1&b=2`, dropping empty values. */
export function apiUrl(path: string, params?: Record<string, ParamValue>): string {
if (!params) return path
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value === '') continue
search.set(key, String(value))
}
const qs = search.toString()
return qs ? `${path}?${qs}` : path
}
/** One redirect per session, however many requests race into a 401. */
let redirecting = false
function sendToLogin(): void {
if (redirecting) return
redirecting = true
const next = typeof location === 'undefined' ? '' : location.pathname
location.assign(`/auth/login?next=${encodeURIComponent(next)}`)
}
function isLoginPath(path: string): boolean {
return path.includes('/auth/login')
}
async function errorMessage(res: Response): Promise<{ message: string; body: string }> {
let body = ''
try {
body = await res.text()
} catch {
body = ''
}
let message = body
if (body) {
try {
const parsed: unknown = JSON.parse(body)
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
const err = (parsed as { error: unknown }).error
if (typeof err === 'string' && err !== '') message = err
}
} catch {
/* not JSON: show the raw text */
}
}
if (message === '') message = res.statusText || `HTTP ${res.status}`
return { message, body }
}
/**
* `fetch` with JSON conveniences, the API key header and a single 401
* redirect. Non-2xx always throws so callers can surface the message.
*/
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers)
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
const hasBody = init?.body !== undefined && init?.body !== null
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token && !headers.has('X-API-Key')) headers.set('X-API-Key', token)
let res: Response
try {
res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
} catch (err) {
// Network failure, DNS, offline, or an aborted request from a unmounted
// component: never surface AbortError noise to the user.
if (err instanceof DOMException && err.name === 'AbortError') throw err
const message = err instanceof Error ? err.message : 'network request failed'
throw new ApiError(0, `cannot reach the fancy-dns API: ${message}`)
}
if (res.status === 401) {
// A failed login is not a dead session: do not bounce the login page to
// itself.
if (!isLoginPath(path)) {
clearToken()
sendToLogin()
}
const { message, body } = await errorMessage(res)
throw new ApiError(401, message, body)
}
if (!res.ok) {
const { message, body } = await errorMessage(res)
throw new ApiError(res.status, message, body)
}
if (res.status === 204 || res.headers.get('Content-Length') === '0') {
return undefined as T
}
const text = await res.text()
if (text === '') return undefined as T
try {
return JSON.parse(text) as T
} catch {
throw new ApiError(res.status, 'the API returned a malformed response', text)
}
}
/** Percent-encodes a single path segment. */
function seg(value: string): string {
return encodeURIComponent(value)
}
export async function login(body: LoginInput): Promise<LoginResponse> {
const res = await apiFetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(body),
})
if (res?.token) setToken(res.token)
return res
}
export async function logout(): Promise<OkResponse> {
try {
return await apiFetch<OkResponse>('/api/auth/logout', { method: 'POST' })
} finally {
clearToken()
}
}
export function whoami(): Promise<WhoamiResponse> {
return apiFetch<WhoamiResponse>('/api/whoami')
}
export function getDashboard(range: string): Promise<Dashboard> {
return apiFetch<Dashboard>(apiUrl('/api/dashboard', { range }))
}
export function getBlocklists(): Promise<BlocklistsResponse> {
return apiFetch<BlocklistsResponse>('/api/blocklists')
}
export async function refreshBlocklists(names?: string[]): Promise<RefreshBlocklistsResponse> {
if (names && names.length > 0) {
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', {
method: 'POST',
body: JSON.stringify({ names }),
})
}
return apiFetch<RefreshBlocklistsResponse>('/api/blocklists/refresh', { method: 'POST' })
}
export function toggleBlocklist(
name: string,
enabled: boolean,
): Promise<ToggleBlocklistResponse> {
return apiFetch<ToggleBlocklistResponse>('/api/blocklists/toggle', {
method: 'POST',
body: JSON.stringify({ name, enabled }),
})
}
export function getDomains(filters: DomainFilters = {}): Promise<DomainsResponse> {
return apiFetch<DomainsResponse>(apiUrl('/api/domains', { ...filters }))
}
export function addDomain(input: AddDomainInput): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/domains', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function deleteDomain(name: string, action?: string, list?: string): Promise<OkResponse> {
return apiFetch<OkResponse>(apiUrl(`/api/domains/${seg(name)}`, { action, list }), {
method: 'DELETE',
})
}
export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
export function getClients(): Promise<ClientsResponse> {
return apiFetch<ClientsResponse>('/api/clients')
}
export function putClients(payload: ClientsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/clients', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getUpstreams(): Promise<UpstreamsResponse> {
return apiFetch<UpstreamsResponse>('/api/upstreams')
}
export function putUpstreams(payload: UpstreamsPayload): Promise<SaveResponse> {
return apiFetch<SaveResponse>('/api/upstreams', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function getLogs(filters: LogFilters = {}): Promise<LogsResponse> {
return apiFetch<LogsResponse>(apiUrl('/api/logs', { ...filters }))
}
export function clearLogs(): Promise<OkResponse> {
return apiFetch<OkResponse>('/api/logs/clear', { method: 'POST' })
}
export function getCache(): Promise<CacheResponse> {
return apiFetch<CacheResponse>('/api/cache')
}
export function getCacheTop(limit = 50): Promise<CacheTopResponse> {
return apiFetch<CacheTopResponse>(apiUrl('/api/cache/top', { limit }))
}
export function flushCache(): Promise<FlushCacheResponse> {
return apiFetch<FlushCacheResponse>('/api/cache/flush', { method: 'POST' })
}
export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings')
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function validateSettings(yaml: string): Promise<ValidateResponse> {
return apiFetch<ValidateResponse>('/api/settings/validate', {
method: 'POST',
body: JSON.stringify({ config_yaml: yaml }),
})
}
export function reloadSettings(): Promise<ReloadSettingsResponse> {
return apiFetch<ReloadSettingsResponse>('/api/settings/reload', { method: 'POST' })
}
const STREAM_PATH = '/api/logs/stream'
const RECONNECT_MIN_MS = 1000
const RECONNECT_MAX_MS = 15000
/**
* Subscribes to the live query log.
*
* Native `EventSource` cannot send the `X-API-Key` header and the daemon
* accepts no cookie, so the stream is read with `fetch` plus a minimal SSE
* parser. That costs a hand-rolled reconnect, which is what EventSource would
* have given us for free; the handler contract is unchanged.
*
* Returns a teardown function that is safe to call more than once.
*/
export function streamLogs(handlers: LogStreamHandlers): () => void {
const controller = new AbortController()
let stopped = false
let backoff = RECONNECT_MIN_MS
let timer: ReturnType<typeof setTimeout> | undefined
let attempt = 0
const open = (): void => {
if (stopped) return
void run()
}
const run = async (): Promise<void> => {
if (stopped) return
const token = getToken()
const headers = new Headers({ Accept: 'text/event-stream' })
if (token) headers.set('X-API-Key', token)
try {
const res = await fetch(STREAM_PATH, {
headers,
credentials: 'same-origin',
signal: controller.signal,
cache: 'no-store',
})
if (res.status === 401) {
clearToken()
sendToLogin()
handlers.onError('unauthorized: the live log stream needs a valid session')
return
}
if (!res.ok || !res.body) {
const message = res.ok
? 'the live log stream is not supported by this browser'
: `the live log stream failed with HTTP ${res.status}`
handlers.onError(message)
schedule()
return
}
attempt = 0
backoff = RECONNECT_MIN_MS
handlers.onOpen()
await readSse(res.body, handlers, controller.signal)
if (stopped) return
// A clean end of stream still means the connection is gone.
schedule()
} catch (err) {
if (stopped) return
if (controller.signal.aborted) return
const message = err instanceof Error ? err.message : 'the live log stream dropped'
handlers.onError(message)
schedule()
}
}
const schedule = (): void => {
if (stopped) return
attempt += 1
const delay = Math.min(RECONNECT_MAX_MS, backoff)
backoff = Math.min(RECONNECT_MAX_MS, Math.round(backoff * 1.8))
timer = setTimeout(open, delay)
}
open()
return () => {
if (stopped) return
stopped = true
if (timer !== undefined) clearTimeout(timer)
controller.abort()
}
}
/** Splits an SSE byte stream into `event:`/`data:` frames. */
async function readSse(
body: ReadableStream<Uint8Array>,
handlers: LogStreamHandlers,
signal: AbortSignal,
): Promise<void> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const handle = (frame: string): void => {
let event = 'message'
const data: string[] = []
for (const rawLine of frame.split('\n')) {
const line = rawLine.replace(/\r$/, '')
if (line === '' || line.startsWith(':')) continue
if (line.startsWith('event:')) event = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
}
if (data.length === 0) return
let payload: unknown
try {
payload = JSON.parse(data.join('\n'))
} catch {
return
}
if (event === 'entry') {
handlers.onEntry(payload as LogItem)
} else if (event === 'stats') {
handlers.onStats(payload as LiveStats)
} else if (event === 'ready') {
handlers.onReady(payload as { at?: string; version?: string })
}
}
try {
for (;;) {
if (signal.aborted) return
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let split = buffer.indexOf('\n\n')
while (split !== -1) {
const frame = buffer.slice(0, split)
buffer = buffer.slice(split + 2)
handle(frame)
split = buffer.indexOf('\n\n')
}
}
if (buffer.trim() !== '') handle(buffer)
} finally {
try {
reader.releaseLock()
} catch {
/* already released by the abort */
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from 'clsx'
import type { ClassValue } from 'clsx'
/** Conditional className helper used by every component. */
export function cn(...values: ClassValue[]): string {
return clsx(values)
}
+168
View File
@@ -0,0 +1,168 @@
/** Consistent human formatting for every number, size and duration in the UI. */
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
/** 1_048_576 -> "1.0 MB". Decimal (1000) is used above MB like most disks do. */
export function formatBytes(bytes: number | null | undefined, digits = 1): string {
const n = typeof bytes === 'number' && Number.isFinite(bytes) ? bytes : 0
if (n <= 0) return '0 B'
const base = n < 1000 ? 1024 : 1000
let value = n
let unit = 0
while (value >= base && unit < BYTE_UNITS.length - 1) {
value /= base
unit += 1
}
const precision = unit === 0 ? 0 : value >= 100 ? 0 : digits
return `${value.toFixed(precision)} ${BYTE_UNITS[unit]}`
}
/** 1_234_567 -> "1,234,567". */
export function formatNumber(value: number | null | undefined): string {
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
return n.toLocaleString('en-US')
}
/** 0.9432 -> "94.3%". Accepts 0..1 (fractions) and 0..100 (percents). */
export function formatPercent(fraction: number | null | undefined, digits = 1): string {
const n = typeof fraction === 'number' && Number.isFinite(fraction) ? fraction : 0
const scaled = n > 1 ? n : n * 100
return `${scaled.toFixed(digits)}%`
}
/** 0.043 -> "4.3%" — for rates the API reports as 0..1. */
export function formatRate(fraction: number | null | undefined, digits = 1): string {
return formatPercent(fraction, digits)
}
/** Milliseconds: 0.42 -> "420 µs", 12.5 -> "12.5 ms", 2400 -> "2.40 s". */
export function formatMs(ms: number | null | undefined): string {
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
if (n <= 0) return '0 ms'
if (n < 1) return `${Math.round(n * 1000)} µs`
if (n < 1000) return `${n < 10 ? n.toFixed(1) : Math.round(n).toString()} ms`
return `${(n / 1000).toFixed(2)} s`
}
/** Seconds -> "3d 4h 12m". */
export function formatUptime(seconds: number | null | undefined): string {
const total = typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(0, seconds) : 0
if (total < 1) return '0s'
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = Math.floor(total % 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
if (minutes > 0) return `${minutes}m ${secs}s`
return `${secs}s`
}
/** Parses RFC3339 strings and epoch numbers (seconds or milliseconds). */
export function toDate(value: string | number | null | undefined): Date | null {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') {
if (!Number.isFinite(value) || value <= 0) return null
const ms = value > 1e11 ? value : value * 1000
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? null : d
}
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
/** "3 minutes ago" / "in 2 hours". */
export function formatRelative(
value: string | number | null | undefined,
now: number = Date.now(),
): string {
const date = toDate(value)
if (!date) return 'never'
const diffSec = Math.round((date.getTime() - now) / 1000)
const abs = Math.abs(diffSec)
if (abs < 5) return 'just now'
if (abs < 60) return diffSec < 0 ? `${abs}s ago` : `in ${abs}s`
const mins = Math.round(abs / 60)
if (mins < 60) return diffSec < 0 ? `${mins}m ago` : `in ${mins}m`
const hours = Math.round(mins / 60)
if (hours < 24) return diffSec < 0 ? `${hours}h ago` : `in ${hours}h`
const days = Math.round(hours / 24)
if (days < 365) return diffSec < 0 ? `${days}d ago` : `in ${days}d`
const years = (days / 365).toFixed(1)
return diffSec < 0 ? `${years}y ago` : `in ${years}y`
}
/** "14:03:22" in the viewer's locale. */
export function formatClock(value: string | number | Date | null | undefined): string {
const date = value instanceof Date ? value : toDate(value ?? null)
if (!date) return '--:--:--'
return date.toLocaleTimeString('en-GB', { hour12: false })
}
/** "12 Mar 14:03:22". */
export function formatDateTime(value: string | number | null | undefined): string {
const date = toDate(value ?? null)
if (!date) return '—'
return `${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })} ${formatClock(
date,
)}`
}
/** Query/sec-ish compact form for chart axes. */
export function formatCompact(value: number | null | undefined): string {
const n = typeof value === 'number' && Number.isFinite(value) ? value : 0
const abs = Math.abs(n)
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${(n / 1_000).toFixed(abs >= 10_000 ? 0 : 1)}k`
return abs >= 10 ? String(Math.round(n)) : n.toFixed(abs >= 1 ? 1 : 2)
}
/** 3.42 -> "3.4" — used for latency bar labels. */
export function formatLatency(ms: number | null | undefined): string {
const n = typeof ms === 'number' && Number.isFinite(ms) ? ms : 0
if (n <= 0) return '—'
return n < 10 ? n.toFixed(1) : String(Math.round(n))
}
/** DNS rcode number + short name, e.g. 3 -> "3 NXDOMAIN". */
export function formatRcode(rcode: number | string | null | undefined): string {
const code = typeof rcode === 'number' ? rcode : Number.parseInt(String(rcode ?? ''), 10)
if (!Number.isFinite(code)) return '—'
return `${code} ${RCODE_NAMES[code] ?? 'UNKNOWN'}`
}
export const RCODE_NAMES: Record<number, string> = {
0: 'NOERROR',
1: 'FORMERR',
2: 'SERVFAIL',
3: 'NXDOMAIN',
4: 'NOTIMP',
5: 'REFUSED',
6: 'YXDOMAIN',
7: 'YXRRSET',
8: 'NXRRSET',
9: 'NOTAUTH',
10: 'NOTZONE',
16: 'BADVERS',
17: 'BADKEY',
18: 'BADTIME',
19: 'BADMODE',
20: 'BADNAME',
21: 'BADALG',
22: 'BADTRUNC',
23: 'BADCOOKIE',
}
/** Splits a textarea into trimmed, non-empty lines. */
export function toLines(value: string): string[] {
return value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
/** Joins an array back into a textarea value. */
export function fromLines(lines: string[] | null | undefined): string {
if (!lines || lines.length === 0) return ''
return lines.join('\n')
}
+298
View File
@@ -0,0 +1,298 @@
/** API shapes, mirroring the Go JSON tags exactly. */
export type Health = 'ok' | 'degraded' | 'down'
export type UpstreamHealth = 'up' | 'down' | 'degraded'
export type LogAction = 'allow' | 'block' | 'cached' | 'error' | 'drop'
export type DomainAction = 'allow' | 'block'
export type ListFormat = 'auto' | 'hosts' | 'adblock' | 'plain' | 'rpz'
export type UpstreamProto = 'udp' | 'tcp' | 'tls'
export type UpstreamPolicy = 'parallel' | 'random' | 'failover'
export type GroupPolicy = 'allow' | 'block_only' | 'deny'
export type DefaultPolicy = 'allow' | 'deny' | 'block_only'
export interface SeriesPoint {
t: number
queries: number
blocked: number
errors: number
cache_hits: number
}
export interface TopBlockedDomain {
domain: string
count: number
list: string
}
export interface TopQueriedDomain {
domain: string
count: number
qtype: string
blocked: boolean
}
export interface UpstreamStat {
addr: string
proto: string
health: UpstreamHealth
latency_ms: number
fail_rate: number
queries: number
avg_ms: number
p95_ms: number
}
export interface Dashboard {
health: Health
version: string
node_id: string
uptime_seconds: number
queries_total: number
queries_per_sec: number
cache_hit_rate: number
blocked_total: number
blocked_pct: number
series: SeriesPoint[]
top_blocked: TopBlockedDomain[]
top_queried: TopQueriedDomain[]
upstreams: UpstreamStat[]
}
export interface BlocklistItem {
name: string
format: ListFormat | string
enabled: boolean
url: string
path: string
rules: number
bytes: number
updated_at: string
duration_ms: number
err: string
etag: string
categories: string[]
stale: boolean
last_modified: string
}
export interface BlocklistStats {
total: number
exact: number
subdomain_rules: number
root_rules: number
allow: number
block: number
duplicates: number
sources: number
built_at: string
}
export interface BlocklistsResponse {
items: BlocklistItem[]
stats: BlocklistStats
}
export interface DomainItem {
name: string
action: DomainAction | string
list: string
source: string
categories: string[]
hits: number
updated_at: string
}
export interface DomainsResponse {
items: DomainItem[]
total: number
page: number
per_page: number
}
export interface DomainCheck {
name: string
action: DomainAction | string
source: string
categories: string[]
hits: number
rule: string
matched_pattern: string
}
export interface ClientMatch {
cidrs: string[]
ptr_suffixes: string[]
tags: string[]
any: boolean
}
export interface ClientGroup {
name: string
comment: string
policy: GroupPolicy | string
match: ClientMatch
allow_domains: string[]
deny_domains: string[]
skip_blocklist: boolean
skip_cache: boolean
rate_limit: number
max_concurrent: number
log_queries: boolean
upstreams: string[]
}
export interface ClientsResponse {
groups: ClientGroup[]
default_policy: DefaultPolicy | string
}
export interface UpstreamServer {
addr: string
proto: UpstreamProto | string
server_name: string
weight: number
comment: string
health: UpstreamHealth
latency_ms: number
fail_rate: number
queries: number
avg_ms: number
p95_ms: number
breaker_open: boolean
}
export interface UpstreamBootstrap {
name: string
addresses: string[]
refresh: string
}
export interface UpstreamsResponse {
policy: UpstreamPolicy | string
servers: UpstreamServer[]
bootstraps: UpstreamBootstrap[]
healthcheck_interval: string
max_attempts: number
breaker_threshold: number
}
export interface LogItem {
id: number | string
ts: string
client: string
client_group: string
name: string
qtype: string
rcode: number
action: LogAction | string
response: string
duration_ms: number
upstream: string
list: string
}
export interface LogsResponse {
items: LogItem[]
total: number
offset: number
}
export interface LiveStats {
qps: number
blocked_pct: number
cache_hit_rate: number
}
export interface CachePersisted {
enabled: boolean
path: string
bytes: number
entries: number
last_snapshot: string
writes: number
}
export interface CacheResponse {
enabled: boolean
size: number
entries: number
shards: number
min_ttl_ms: number
max_ttl_ms: number
serve_stale_ms: number
negative_ttl_ms: number
hit_rate: number
hits: number
misses: number
stale_hits: number
evictions: number
persisted: CachePersisted
}
export interface CacheTopEntry {
name: string
qtype: string
count: number
hits: number
last_seen: string
}
export interface CacheTopResponse {
items: CacheTopEntry[]
}
export interface SettingsResponse {
version: string
path: string
config_yaml: string
effective_yaml: string
defaults_yaml: string
last_error: string
reload_available: boolean
writable: boolean
}
export interface SaveSettingsResponse {
ok: boolean
version: string
warnings: string[]
}
export interface ConfigIssue {
field: string
message: string
}
export interface ValidateResponse {
ok: boolean
errors: ConfigIssue[]
warnings: ConfigIssue[]
}
export interface OkResponse {
ok: boolean
}
export interface LoginResponse {
ok: boolean
token?: string
}
export interface DomainFilters {
q?: string
action?: string
list?: string
page?: number
per_page?: number
}
export interface LogFilters {
limit?: number
offset?: number
q?: string
action?: string
client?: string
rcode?: string
blocked?: boolean
since?: string
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+342
View File
@@ -0,0 +1,342 @@
/**
* Blocklists — the compiled rule sources.
*
* Two read paths (`/api/blocklists`) and three mutations (toggle, refresh one,
* refresh all). Toggles are optimistic because the endpoint is a single write
* to an in-memory flag: the switch has to feel instant or it feels broken.
*/
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api'
import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
import { useDebounced } from '../../hooks/useDebounced'
import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format'
import {
Badge,
Button,
Card,
CardBody,
EmptyState,
IconButton,
Input,
StatCard,
Switch,
Table,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const EMPTY_STATS: BlocklistStats = {
total: 0,
exact: 0,
subdomain_rules: 0,
root_rules: 0,
allow: 0,
block: 0,
duplicates: 0,
sources: 0,
built_at: '',
}
type CheckView = Partial<DomainCheck> & {
list?: string
pattern?: string
error?: string
/** Older daemons called the matched rule "rule". */
rule?: string
matched_pattern?: string
}
/** The lookup endpoint answers 200 with an `error` string, not a 4xx. */
function checkView(value: unknown): CheckView | null {
if (!value || typeof value !== 'object') return null
return value as CheckView
}
function truncate(value: string, max = 42): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
}
export default function Blocklists() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
const [probe, setProbe] = useState('')
const debouncedProbe = useDebounced(probe, 300)
const name = debouncedProbe.trim()
const { data: checked, error: checkError, loading: checking } = useApi(
() => (name.length > 0 ? checkDomain(name) : Promise.resolve(null)),
[name],
)
const result = checkView(checked)
const stats = (data?.stats as BlocklistStats | undefined) ?? EMPTY_STATS
const items: BlocklistItem[] = useMemo(() => data?.items ?? [], [data])
const compiledAgo = useMemo(() => {
const built = toDate(stats.built_at)
if (built) return formatRelative(built.toISOString())
const newest = items
.map((i) => toDate(i.updated_at))
.filter((d): d is Date => d !== null)
.sort((a, b) => b.getTime() - a.getTime())[0]
return newest ? formatRelative(newest.toISOString()) : 'never'
}, [stats.built_at, items])
const toggle = useAsyncAction(async (list: string, enabled: boolean) =>
toggleBlocklist(list, enabled),
)
const refresh = useAsyncAction(async (names?: string[]) => refreshBlocklists(names))
const onToggle = async (item: BlocklistItem, next: boolean): Promise<void> => {
setOverrides((prev) => ({ ...prev, [item.name]: next }))
const applied = await toggle.run(item.name, next)
if (applied === null) {
setOverrides((prev) => {
const copy = { ...prev }
delete copy[item.name]
return copy
})
notify(toast, 'fail', toggle.error ?? `could not toggle ${item.name}`)
return
}
refetch()
}
const onRefresh = async (names?: string[]): Promise<void> => {
const key = names?.join(',') ?? ''
setPendingRows((prev) => ({ ...prev, [key]: true }))
const res = await refresh.run(names)
setPendingRows((prev) => ({ ...prev, [key]: false }))
if (res === null) {
notify(toast, 'fail', refresh.error ?? 'blocklist refresh failed')
return
}
notify(
toast,
'ok',
names && names.length > 0
? `refresh started for ${names.join(', ')}`
: 'refresh started for every source',
)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'List', width: '22%' },
{ key: 'format', header: 'Format', width: '10%' },
{ key: 'enabled', header: 'Enabled', width: '10%' },
{ key: 'rules', header: 'Rules', width: '9%', align: 'right' as const },
{ key: 'size', header: 'Size', width: '9%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '12%' },
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
{ key: 'source', header: 'Source', width: '24%' },
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
]
const rows: Row[] = items.map((item) => {
const enabled = overrides[item.name] ?? item.enabled
const busy = pendingRows[item.name] === true
return {
__key: item.name,
name: (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.name}</span>
{item.err ? (
<span className="truncate text-xs text-red-600 dark:text-red-400">{item.err}</span>
) : null}
</span>
),
format: <Badge tone="muted">{item.format}</Badge>,
enabled: (
<Switch
checked={enabled}
onChange={(next) => void onToggle(item, next)}
label={enabled ? 'on' : 'off'}
/>
),
rules: <span className="font-mono text-xs">{formatNumber(item.rules)}</span>,
size: <span className="font-mono text-xs">{formatBytes(item.bytes)}</span>,
updated: (
<span className="flex items-center gap-1.5 whitespace-nowrap text-xs">
{formatRelative(item.updated_at)}
{item.stale ? <Badge tone="warn">stale</Badge> : null}
</span>
),
duration: <span className="font-mono text-xs">{formatMs(item.duration_ms)}</span>,
source: (
<span
className="block max-w-[240px] truncate font-mono text-xs text-slate-500 dark:text-slate-400"
title={item.url || item.path}
>
{truncate(item.url || item.path)}
</span>
),
refresh: (
<IconButton
icon={<RefreshCw size={14} />}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy}
onClick={() => void onRefresh([item.name])}
/>
),
}
})
const action = result?.action ?? null
const matchedList = result?.source || result?.list || ''
const matchedPattern = result?.matched_pattern || result?.rule || result?.pattern || ''
return (
<div className="space-y-4">
<PageHeader
title="Blocklists"
description="Compiled rule sources, their freshness and their health."
actions={
<Button
icon={<RefreshCw size={14} />}
onClick={() => void onRefresh()}
loading={refresh.pending}
>
Refresh all
</Button>
}
/>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
<StatCard label="Total rules" value={formatNumber(stats.total)} icon={<Layers size={16} />} />
<StatCard label="Sources" value={formatNumber(stats.sources)} hint={`${items.filter((i) => i.enabled).length} enabled`} />
<StatCard
label="Allow rules"
value={formatNumber(stats.allow)}
icon={<ShieldCheck size={16} />}
tone={stats.allow > 0 ? 'default' : 'warn'}
/>
<StatCard
label="Duplicates"
value={formatNumber(stats.duplicates)}
icon={<Split size={16} />}
tone={stats.duplicates > 0 ? 'warn' : 'ok'}
/>
<StatCard
label="Last compile"
value={compiledAgo}
icon={<Clock size={16} />}
hint={stats.built_at ? undefined : 'no build recorded'}
/>
</div>
<Card
title="Test a domain"
subtitle="How would the current blocklists treat this name? Nothing is changed."
>
<CardBody>
<div className="flex flex-col gap-3 md:flex-row md:items-start">
<div className="w-full md:max-w-sm">
<Input
label="Domain"
placeholder="ads.example.com"
value={probe}
onChange={(e) => setProbe(e.target.value)}
icon={<ShieldCheck size={14} />}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="min-w-0 flex-1">
{!name ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
Type a name to see the matching rule, the list it came from and how often it
fired.
</p>
) : checking ? (
<p className="text-sm text-slate-500">Checking {name}…</p>
) : checkError ? (
<p className="text-sm text-red-600 dark:text-red-400">{checkError}</p>
) : result?.error ? (
<p className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertTriangle size={14} aria-hidden="true" />
{result.error}
</p>
) : result ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge tone={action === 'allow' ? 'default' : 'warn'}>{action ?? 'unknown'}</Badge>
<span className="font-mono text-xs">{name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
list: <span className="font-mono">{matchedList || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
rule: <span className="font-mono">{matchedPattern || '—'}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
hits: <span className="font-mono">{formatNumber(result.hits ?? 0)}</span>
</span>
{Array.isArray(result.categories) && result.categories.length > 0 ? (
<span className="flex flex-wrap gap-1">
{result.categories.map((c) => (
<Badge key={c} tone="info">
{c}
</Badge>
))}
</span>
) : null}
</div>
) : (
<p className="text-sm text-slate-500">No answer for {name}.</p>
)}
</div>
</div>
</CardBody>
</Card>
<Card title="Sources" subtitle={`${items.length} configured`} padded={false}>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading && items.length === 0}
empty={
<EmptyState
title="No blocklists configured"
description="Add list sources in the configuration to compile rules."
/>
}
/>
</div>
</Card>
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Trash2 size={12} aria-hidden="true" />
Removing a list is a configuration change: delete its source in Settings, then refresh.
</p>
</div>
)
}
+558
View File
@@ -0,0 +1,558 @@
/**
* Clients — per-client ACL groups.
*
* The whole file is rewritten on every save (`PUT /api/clients`), so the editor
* works on a local draft and only commits on Save. A 400 comes back with the
* offending field paths, which are mapped back onto the draft fields so the
* user sees what is wrong next to the input rather than in a toast alone.
*/
import { useMemo, useState } from 'react'
import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { ApiError, getClients, putClients } from '../../lib/api'
import type { ClientGroup, ClientMatch } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { fromLines, toLines } from '../../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Switch,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const POLICIES = [
{ value: 'allow', label: 'Allow — resolve everything' },
{ value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
{ value: 'deny', label: 'Deny — refuse everything' },
]
const DEFAULT_POLICIES = [
{ value: 'allow', label: 'Allow' },
{ value: 'block_only', label: 'Block only' },
{ value: 'deny', label: 'Deny' },
]
/** Textarea-friendly draft: numbers stay strings until they are parsed. */
type GroupDraft = {
name: string
comment: string
policy: string
cidrs: string
ptr_suffixes: string
tags: string
any: boolean
allow_domains: string
deny_domains: string
skip_blocklist: boolean
skip_cache: boolean
log_queries: boolean
rate_limit: string
max_concurrent: string
upstreams: string
}
const EMPTY_DRAFT: GroupDraft = {
name: '',
comment: '',
policy: 'allow',
cidrs: '',
ptr_suffixes: '',
tags: '',
any: false,
allow_domains: '',
deny_domains: '',
skip_blocklist: false,
skip_cache: false,
log_queries: false,
rate_limit: '0',
max_concurrent: '0',
upstreams: '',
}
function toNumber(value: string): number {
const n = Number.parseInt(value.trim(), 10)
return Number.isFinite(n) && n > 0 ? n : 0
}
function matchOf(g: ClientGroup): ClientMatch {
const m = g.match
return {
cidrs: m?.cidrs ?? [],
ptr_suffixes: m?.ptr_suffixes ?? [],
tags: m?.tags ?? [],
any: m?.any ?? false,
}
}
/** One line describing what the group matches, e.g. "10.0.0.0/8, .lan · *". */
function matchSummary(g: ClientGroup): string {
const m = matchOf(g)
const bits: string[] = []
if (m.any) bits.push('any client')
if (m.cidrs.length > 0) {
const shown = m.cidrs.slice(0, 2).join(', ')
bits.push(m.cidrs.length > 2 ? `${shown} +${m.cidrs.length - 2}` : shown)
}
if (m.ptr_suffixes.length > 0) bits.push(m.ptr_suffixes.join(', '))
if (m.tags.length > 0) bits.push(m.tags.map((t) => `#${t}`).join(' '))
if (bits.length === 0) return 'matches nothing — this group is inactive'
return bits.join(' · ')
}
function fromGroup(g: ClientGroup): GroupDraft {
const m = matchOf(g)
return {
name: g.name ?? '',
comment: g.comment ?? '',
policy: String(g.policy ?? 'allow'),
cidrs: fromLines(m.cidrs),
ptr_suffixes: fromLines(m.ptr_suffixes),
tags: fromLines(m.tags),
any: m.any,
allow_domains: fromLines(g.allow_domains),
deny_domains: fromLines(g.deny_domains),
skip_blocklist: g.skip_blocklist === true,
skip_cache: g.skip_cache === true,
log_queries: g.log_queries === true,
rate_limit: String(g.rate_limit ?? 0),
max_concurrent: String(g.max_concurrent ?? 0),
upstreams: fromLines(g.upstreams),
}
}
function toGroup(d: GroupDraft): ClientGroup {
return {
name: d.name.trim(),
comment: d.comment,
policy: d.policy,
match: {
cidrs: toLines(d.cidrs),
ptr_suffixes: toLines(d.ptr_suffixes),
tags: toLines(d.tags),
any: d.any,
},
allow_domains: toLines(d.allow_domains),
deny_domains: toLines(d.deny_domains),
skip_blocklist: d.skip_blocklist,
skip_cache: d.skip_cache,
rate_limit: toNumber(d.rate_limit),
max_concurrent: toNumber(d.max_concurrent),
log_queries: d.log_queries,
upstreams: toLines(d.upstreams),
}
}
/** `groups[2].match.cidrs` -> `cidrs`. */
function fieldLeaf(path: string): string {
const cleaned = path.replace(/^groups\[\d+\]\.?/, '').replace(/^groups\.\d+\.?/, '')
const parts = cleaned.split('.')
return parts[parts.length - 1] ?? cleaned
}
export default function Clients() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
const [defaultPolicy, setDefaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [draft, setDraft] = useState<GroupDraft>(EMPTY_DRAFT)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [fieldErrors, setFieldErrors] = useState<string[]>([])
const [pendingDelete, setPendingDelete] = useState<number | null>(null)
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
const save = useAsyncAction(async (payload: { groups: ClientGroup[]; default_policy: string }) => {
try {
return await putClients(payload)
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
try {
const parsed: unknown = JSON.parse(err.body)
if (parsed && typeof parsed === 'object') {
const fields = (parsed as { fields?: unknown }).fields
if (Array.isArray(fields)) {
setFieldErrors(fields.filter((f): f is string => typeof f === 'string'))
}
}
} catch {
/* a 400 without JSON is still reported through the toast */
}
}
throw err
}
})
const fieldError = (key: string): string | undefined => {
const leaf = fieldLeaf(key)
return fieldErrors.find((f) => fieldLeaf(f) === leaf)
}
const commit = async (
nextGroups: ClientGroup[],
nextDefault: string,
message: string,
): Promise<boolean> => {
const res = await save.run({ groups: nextGroups, default_policy: nextDefault })
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the client groups')
return false
}
notify(toast, 'ok', message)
refetch()
return true
}
const openNew = (): void => {
setEditingIndex(null)
setDraft(EMPTY_DRAFT)
setFieldErrors([])
setEditorOpen(true)
}
const openEdit = (index: number): void => {
const group = groups[index]
if (!group) return
setEditingIndex(index)
setDraft(fromGroup(group))
setFieldErrors([])
setEditorOpen(true)
}
const duplicate = (index: number): void => {
const group = groups[index]
if (!group) return
const copy = fromGroup(group)
setEditingIndex(null)
setDraft({ ...copy, name: `${copy.name}-copy` })
setFieldErrors([])
setEditorOpen(true)
}
const saveGroup = async (): Promise<void> => {
if (draft.name.trim() === '') {
setFieldErrors(['name'])
notify(toast, 'fail', 'a group needs a name')
return
}
const group = toGroup(draft)
const next = [...groups]
if (editingIndex !== null && next[editingIndex]) {
next[editingIndex] = group
} else {
next.push(group)
}
if (await commit(next, effectiveDefault, `saved ${group.name}`)) {
setEditorOpen(false)
setFieldErrors([])
}
}
const deleteGroup = async (): Promise<void> => {
const index = pendingDelete
if (index === null) return
const group = groups[index]
if (!group) return
const next = groups.filter((_, i) => i !== index)
if (await commit(next, effectiveDefault, `deleted ${group.name}`)) {
setPendingDelete(null)
}
}
const saveDefault = async (next: string): Promise<void> => {
setDefaultDraft(next)
const ok = await commit(groups, next, `default policy set to ${next}`)
if (!ok) {
setDefaultDraft(null)
return
}
setDefaultDraft(null)
}
if (error) return <PageError error={error} onRetry={refetch} />
return (
<div className="space-y-4">
<PageHeader
title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
actions={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add group
</Button>
}
/>
<Card title="Default policy" subtitle="Applied to clients that match no group.">
<div className="w-full sm:max-w-xs">
<Select
label="Policy"
value={effectiveDefault}
disabled={loading || save.pending}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
/>
</div>
</Card>
{groups.length === 0 ? (
<EmptyState
icon={<Users size={20} />}
title="No client groups"
description="Without a group every client gets the default policy."
action={
<Button icon={<Plus size={14} />} onClick={openNew}>
Add the first group
</Button>
}
/>
) : (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{groups.map((group, i) => (
<Card key={group.name}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-medium">{group.name}</h3>
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
{group.policy}
</Badge>
</div>
{group.comment ? (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
) : null}
<p className="mt-1.5 truncate font-mono text-xs text-slate-500 dark:text-slate-400">
{matchSummary(group)}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
{group.log_queries ? <Badge tone="muted">log</Badge> : null}
{group.rate_limit > 0 ? (
<Badge tone="muted">{group.rate_limit} qps</Badge>
) : null}
{group.max_concurrent > 0 ? (
<Badge tone="muted">max {group.max_concurrent}</Badge>
) : null}
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton icon={<Pencil size={14} />} label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} />
<IconButton icon={<Copy size={14} />} label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} />
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${group.name}`}
variant="ghost"
onClick={() => setPendingDelete(i)}
/>
</div>
</div>
</Card>
))}
</div>
)}
<Dialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
description="Groups are matched top to bottom; the first match wins."
size="lg"
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
Cancel
</Button>
<Button onClick={() => void saveGroup()} loading={save.pending}>
{editingIndex === null ? 'Create group' : 'Save changes'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
error={fieldError('name')}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Policy"
value={draft.policy}
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
options={POLICIES}
/>
</div>
<Input
label="Comment"
value={draft.comment}
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Client CIDRs"
hint="One per line, e.g. 192.168.1.0/24"
monospace
rows={4}
value={draft.cidrs}
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
error={fieldError('cidrs')}
/>
<Textarea
label="Reverse-DNS suffixes"
hint="One per line, e.g. .lan"
monospace
rows={4}
value={draft.ptr_suffixes}
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
error={fieldError('ptr_suffixes')}
/>
</div>
<Textarea
label="Tags"
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
monospace
rows={2}
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
error={fieldError('tags')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Allow domains"
hint="Glob patterns that skip the blocklists for this group."
monospace
rows={3}
value={draft.allow_domains}
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
error={fieldError('allow_domains')}
/>
<Textarea
label="Deny domains"
hint="Glob patterns always refused for this group."
monospace
rows={3}
value={draft.deny_domains}
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
error={fieldError('deny_domains')}
/>
</div>
<Textarea
label="Upstreams"
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
monospace
rows={2}
value={draft.upstreams}
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
error={fieldError('upstreams')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Rate limit (qps)"
type="number"
min={0}
value={draft.rate_limit}
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
hint="0 disables the limit."
error={fieldError('rate_limit')}
/>
<Input
label="Max concurrent queries"
type="number"
min={0}
value={draft.max_concurrent}
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
hint="0 means unlimited."
error={fieldError('max_concurrent')}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Switch
checked={draft.any}
onChange={(next) => setDraft((d) => ({ ...d, any: next }))}
label="Match any client"
description="Last-resort group that matches everything not claimed above."
/>
<Switch
checked={draft.skip_blocklist}
onChange={(next) => setDraft((d) => ({ ...d, skip_blocklist: next }))}
label="Skip blocklists"
description="Resolve everything, even listed names."
/>
<Switch
checked={draft.skip_cache}
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
label="Skip cache"
description="Always go upstream, never serve a cached answer."
/>
<Switch
checked={draft.log_queries}
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
label="Log queries"
description="Include this group's queries in the query log."
/>
</div>
{fieldErrors.length > 0 ? (
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
<p className="font-medium">The server rejected these fields:</p>
<ul className="mt-1 list-inside list-disc font-mono text-xs">
{fieldErrors.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</div>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void deleteGroup()}
title="Delete this group?"
message={
pendingDelete !== null && groups[pendingDelete]
? `Clients matching "${groups[pendingDelete].name}" fall back to the default policy.`
: ''
}
confirmLabel="Delete"
destructive
pending={save.pending}
/>
</div>
)
}
+490
View File
@@ -0,0 +1,490 @@
/**
* Dashboard — the "is it working" page.
*
* Everything on it is a single `/api/dashboard` fetch for the selected range;
* the charts are pure functions of `series`, so they never need their own
* polling. The server builds that payload, so every field is read defensively:
* a node running an older build simply shows less, it never throws.
*/
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import {
Area,
Bar,
BarChart,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip as ChartTooltip,
XAxis,
YAxis,
} from 'recharts'
import { Activity, Ban, Gauge, RefreshCw, Timer } from 'lucide-react'
import { getDashboard } from '../../lib/api'
import type { Dashboard as DashboardData, LogItem, UpstreamStat } from '../../lib/types'
import { useApi } from '../../hooks/useApi'
import { useAutoRefresh } from '../../hooks/useAutoRefresh'
import { TIME_RANGES, useTimeRange } from '../../hooks/useTimeRange'
import {
formatClock,
formatCompact,
formatMs,
formatNumber,
formatPercent,
formatRate,
formatUptime,
} from '../../lib/format'
import { Badge, Button, Card, CardBody, EmptyState, Progress, StatCard, Table } from '../components/ui'
import { HealthPill, PageError, PageHeader } from '../components/layout'
/** Chart palette: fixed hex so it reads on both the light and dark theme. */
const GRID = 'rgba(148, 163, 184, 0.22)'
const AXIS_TEXT = '#94a3b8'
const QUERIES = '#38bdf8'
const CACHED = '#34d399'
const BLOCKED = '#f59e0b'
const ERRORS = '#f87171'
const TIP_BG = '#0f172a'
/** The parts of the dashboard payload that older daemons may not send. */
type DashboardView = Partial<DashboardData> & {
degraded_reason?: string
reason?: string
message?: string
latency?: { p50_ms?: number; p95_ms?: number; p99_ms?: number }
recent?: LogItem[]
}
type Row = Record<string, ReactNode>
function view(data: DashboardData | null): DashboardView {
return (data ?? {}) as DashboardView
}
/** `useToast()` shape is the UI kit's business; this just needs a call. */
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
type TipProps = {
active?: boolean
label?: number | string
payload?: { name?: string; value?: number | string; color?: string }[]
}
/** Dark tooltip content, so the chart stays legible in the light theme too. */
function ChartTip({ active, payload, label }: TipProps) {
if (!active || !payload || payload.length === 0) return null
return (
<div className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-xs shadow-lg">
<div className="mb-1 font-mono text-slate-400">{formatClock(Number(label) * 1000)}</div>
<ul className="space-y-0.5">
{payload.map((item) => (
<li key={item.name} className="flex items-center gap-2 text-slate-100">
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: item.color }}
/>
<span className="text-slate-400">{item.name}</span>
<span className="ml-auto font-mono">{formatNumber(Number(item.value ?? 0))}</span>
</li>
))}
</ul>
</div>
)
}
function healthTone(health: string | undefined): 'default' | 'ok' | 'warn' | 'danger' {
if (health === 'ok') return 'ok'
if (health === 'degraded') return 'warn'
return 'danger'
}
function seriesOf(d: DashboardView) {
return d.series ?? []
}
export default function Dashboard() {
const { range, setRange } = useTimeRange('1h')
const { data, error, loading, refetch } = useApi(() => getDashboard(range), [range])
useAutoRefresh(refetch, 5000)
const d = view(data)
const series = useMemo(() => seriesOf(d), [d])
const upstreams = useMemo(() => d.upstreams ?? [], [d])
const slowest = useMemo(
() => upstreams.reduce((max, u) => Math.max(max, u.latency_ms || 0), 0),
[upstreams],
)
const topBlocked = useMemo(() => d.top_blocked ?? [], [d])
const topQueried = useMemo(() => d.top_queried ?? [], [d])
const hitRate = d.cache_hit_rate ?? 0
const p99 = d.latency?.p99_ms
const p95Fallback = useMemo(() => {
const values = upstreams.map((u) => u.p95_ms || 0).filter((v) => v > 0)
return values.length > 0 ? Math.max(...values) : undefined
}, [upstreams])
const latencyValue = p99 ?? p95Fallback
const latencyLabel = p99 != null ? 'p99 latency' : 'p95 latency'
const degraded = d.health != null && d.health !== 'ok'
const degradedReason =
d.degraded_reason ?? d.reason ?? d.message ?? 'no reason reported by the server'
if (error) return <PageError error={error} onRetry={refetch} />
const upstreamColumns = [
{ key: 'addr', header: 'Server', width: '28%' },
{ key: 'proto', header: 'Proto', width: '8%' },
{ key: 'health', header: 'Health', width: '12%' },
{ key: 'latency', header: 'Latency', width: '22%' },
{ key: 'fail', header: 'Fail rate', width: '12%', align: 'right' as const },
{ key: 'queries', header: 'Queries', width: '14%', align: 'right' as const },
]
const upstreamRows: Row[] = upstreams.map((u: UpstreamStat, i) => ({
__key: `${u.addr}-${i}`,
addr: <span className="font-mono text-xs">{u.addr}</span>,
proto: <Badge tone="muted">{u.proto}</Badge>,
health: <Badge tone={healthTone(u.health)}>{u.health}</Badge>,
latency: (
<span className="flex items-center gap-2">
<span className="w-12 shrink-0 font-mono text-xs text-slate-500 dark:text-slate-400">
{formatMs(u.latency_ms)}
</span>
<span className="w-full min-w-[60px]">
<Progress
value={u.latency_ms}
max={slowest || 1}
tone={u.fail_rate > 0.1 ? 'danger' : u.fail_rate > 0.02 ? 'warn' : 'ok'}
/>
</span>
</span>
),
fail: (
<span className="font-mono text-xs">{u.fail_rate > 0 ? formatRate(u.fail_rate) : '—'}</span>
),
queries: <span className="font-mono text-xs">{formatNumber(u.queries)}</span>,
}))
const recent: LogItem[] = d.recent ?? []
const recentSource: { name: string; list: string; action: string; ts?: string }[] =
recent.length > 0
? recent.slice(0, 10).map((e) => ({
name: e.name,
list: e.list,
action: e.action,
ts: e.ts,
}))
: topBlocked.slice(0, 10).map((e) => ({ name: e.domain, list: e.list, action: 'block' }))
return (
<div className="space-y-4">
<PageHeader
title="Dashboard"
description="Live traffic, cache efficiency and upstream health."
actions={
<div className="flex items-center gap-2">
<div
className="flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
role="group"
aria-label="Time range"
>
{TIME_RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={
range === r
? 'bg-slate-900 px-2.5 py-1 text-xs font-medium text-white dark:bg-slate-100 dark:text-slate-900'
: 'px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}
>
{r}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
icon={<RefreshCw size={14} />}
onClick={refetch}
loading={loading}
>
Refresh
</Button>
</div>
}
/>
{degraded && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-200"
>
<Activity size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<p className="font-semibold">The server reports degraded service</p>
<p className="mt-0.5 break-words opacity-90">{degradedReason}</p>
</div>
<Badge tone={healthTone(d.health)}>{d.health}</Badge>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Queries / s"
value={formatCompact(d.queries_per_sec ?? 0)}
hint={`${formatNumber(d.queries_total ?? 0)} total`}
icon={<Activity size={16} />}
/>
<StatCard
label="Cache hit rate"
value={formatRate(hitRate)}
hint="higher is better"
icon={<Gauge size={16} />}
tone={hitRate >= 0.7 ? 'ok' : hitRate >= 0.4 ? 'warn' : 'danger'}
/>
<StatCard
label="Blocked"
value={formatPercent(d.blocked_pct ?? 0)}
hint={`${formatNumber(d.blocked_total ?? 0)} queries`}
icon={<Ban size={16} />}
/>
<StatCard
label={latencyLabel}
value={latencyValue != null ? formatMs(latencyValue) : '—'}
hint={p99 != null ? 'from the server' : 'slowest upstream p95'}
icon={<Timer size={16} />}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
version {d.version || 'unknown'} · up {formatUptime(d.uptime_seconds ?? 0)}
{d.node_id ? ` · node ${d.node_id}` : ''} · auto-refreshing every 5s
</p>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Queries" subtitle={`Last ${range} · total, cache hits and errors`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Area
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
fill={QUERIES}
fillOpacity={0.18}
strokeWidth={1.5}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="cache_hits"
name="cached"
stroke={CACHED}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="errors"
name="errors"
stroke={ERRORS}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card title="Blocked rate" subtitle={`Blocked queries per bucket, last ${range}`}>
<CardBody>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={series} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="t"
tickFormatter={formatClock}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
minTickGap={24}
/>
<YAxis
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
width={48}
/>
<ChartTooltip content={<ChartTip />} cursor={{ stroke: GRID }} />
<Bar
dataKey="blocked"
name="blocked"
fill={BLOCKED}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="queries"
name="queries"
stroke={QUERIES}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<Card title="Top blocked domains" subtitle="Ranked by hits in the selected range">
<CardBody>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={topBlocked}
layout="vertical"
margin={{ top: 0, right: 16, bottom: 0, left: 0 }}
>
<CartesianGrid stroke={GRID} strokeDasharray="3 3" horizontal={false} />
<XAxis
type="number"
tickFormatter={formatCompact}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
/>
<YAxis
type="category"
dataKey="domain"
width={180}
tick={{ fill: AXIS_TEXT, fontSize: 11 }}
stroke={GRID}
tickFormatter={(value: string) =>
value.length > 26 ? `${value.slice(0, 25)}…` : value
}
/>
<ChartTooltip content={<ChartTip />} cursor={{ fill: 'rgba(148,163,184,0.12)' }} />
<Bar dataKey="count" name="blocked" fill={BLOCKED} radius={[0, 3, 3, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</CardBody>
</Card>
<Card
title="Recent blocks"
subtitle={recent.length > 0 ? 'Newest entries from the server' : 'From the top-blocked ranking'}
>
<CardBody>
{recentSource.length === 0 ? (
<EmptyState
title="Nothing blocked yet"
description="No blocked queries in the selected range."
/>
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{recentSource.map((e, i) => (
<li key={`${e.name}-${i}`} className="flex items-center gap-3 px-4 py-2">
<Badge tone="warn">{e.action || 'block'}</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{e.name}</span>
{e.list ? (
<span className="hidden truncate text-xs text-slate-500 sm:inline dark:text-slate-400">
{e.list}
</span>
) : null}
{e.ts ? (
<span className="font-mono text-xs text-slate-400">{formatClock(e.ts)}</span>
) : null}
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div className="xl:col-span-2">
<Card
title="Upstreams"
subtitle="Health, latency relative to the slowest server, and fail rate"
actions={<HealthPill health={d.health ?? 'down'} />}
>
<CardBody>
<div className="overflow-x-auto">
<Table
columns={upstreamColumns}
rows={upstreamRows}
rowKey={(row) => String(row.__key)}
empty="No upstreams are configured."
loading={loading && upstreams.length === 0}
/>
</div>
</CardBody>
</Card>
</div>
<Card title="Top queried" subtitle="Most requested names in range">
<CardBody>
{topQueried.length === 0 ? (
<EmptyState title="No query data" description="Nothing was resolved in this range." />
) : (
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{topQueried.slice(0, 15).map((q) => (
<li key={`${q.domain}-${q.qtype}`} className="flex items-center gap-2 px-4 py-2 text-sm">
<Badge tone={q.blocked ? 'warn' : 'default'}>
{q.qtype || '?'}
</Badge>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{q.domain}</span>
<span className="font-mono text-xs text-slate-500">{formatNumber(q.count)}</span>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
)
}
+378
View File
@@ -0,0 +1,378 @@
/**
* Domains — the inline rules that override the downloaded lists.
*
* The table is server-paged, so every filter change rewinds to page 1 and the
* query object is memoised so `useApi` only refetches when something the
* server cares about actually changed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Plus, Search, Trash2 } from 'lucide-react'
import { addDomain, deleteDomain, getDomains } from '../../lib/api'
import type { AddDomainInput } from '../../lib/api'
import type { DomainItem } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { useDebounced } from '../../hooks/useDebounced'
import { formatNumber, formatRelative, toLines } from '../../lib/format'
import {
Badge,
Button,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Pagination,
Select,
Table,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const PER_PAGE = 50
const ACTIONS = [
{ value: 'block', label: 'Block (NXDOMAIN)' },
{ value: 'allow', label: 'Allow (pass through)' },
{ value: 'drop', label: 'Drop (no response)' },
{ value: 'nxdomain', label: 'NXDOMAIN' },
{ value: 'redirect', label: 'Redirect' },
]
const NAME_HINT =
'*.example.com matches subdomains, **.example.com matches the apex too, a bare name matches exactly.'
function actionTone(action: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
switch (action) {
case 'allow':
return 'ok'
case 'block':
return 'warn'
case 'drop':
return 'danger'
case 'nxdomain':
return 'warn'
case 'redirect':
return 'info'
default:
return 'default'
}
}
type AddForm = {
name: string
action: string
list: string
bulk: string
advanced: boolean
}
const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false }
export default function Domains() {
const toast = useToast()
const [search, setSearch] = useState('')
const [action, setAction] = useState('')
const [list, setList] = useState('')
const [page, setPage] = useState(1)
const q = useDebounced(search, 300)
const [addOpen, setAddOpen] = useState(false)
const [form, setForm] = useState<AddForm>(EMPTY_FORM)
const [formError, setFormError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<DomainItem | null>(null)
// A new filter makes the old page number meaningless.
useEffect(() => {
setPage(1)
}, [q, action, list])
const filters = useMemo(
() => ({
q: q.trim(),
action: action === '' ? undefined : action,
list: list === '' ? undefined : list,
page,
per_page: PER_PAGE,
}),
[q, action, list, page],
)
const { data, error, loading, refetch } = useApi(() => getDomains(filters), [filters])
const items: DomainItem[] = useMemo(() => data?.items ?? [], [data])
const total = data?.total ?? items.length
const add = useAsyncAction(async (input: AddDomainInput) => addDomain(input))
const remove = useAsyncAction(async (name: string, act: string, src: string) =>
deleteDomain(name, act, src),
)
const lists = useMemo(() => {
const seen = new Set<string>()
for (const item of items) {
if (item.list) seen.add(item.list)
if (item.source) seen.add(item.source)
}
return Array.from(seen).sort()
}, [items])
const bulkLines = useMemo(() => toLines(form.bulk), [form.bulk])
const canSubmit =
form.action !== '' && (form.bulk.trim() !== '' || form.name.trim() !== '')
const submit = async (): Promise<void> => {
setFormError(null)
if (!canSubmit) {
setFormError('enter a domain, or paste rules in the bulk box')
return
}
if (form.bulk.trim() !== '') {
for (const line of bulkLines) {
const allow = line.startsWith('!')
const ruleName = allow ? line.slice(1).trim() : line
if (ruleName === '') continue
const res = await add.run({
name: ruleName,
action: allow ? 'allow' : form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
notify(toast, 'fail', add.error ?? `could not add ${ruleName}`)
return
}
}
notify(toast, 'ok', `added ${bulkLines.length} rule${bulkLines.length === 1 ? '' : 's'}`)
} else {
const res = await add.run({
name: form.name.trim(),
action: form.action,
list: form.list.trim() || undefined,
source: 'console',
content: '',
})
if (res === null) {
setFormError(add.error ?? 'the server rejected that rule')
notify(toast, 'fail', add.error ?? 'could not add the rule')
return
}
notify(toast, 'ok', `added ${form.name.trim()}`)
}
setForm(EMPTY_FORM)
setAddOpen(false)
refetch()
}
const confirmDelete = async (): Promise<void> => {
const target = pendingDelete
if (!target) return
const res = await remove.run(target.name, String(target.action), target.list || target.source)
if (res === null) {
notify(toast, 'fail', remove.error ?? `could not delete ${target.name}`)
return
}
setPendingDelete(null)
notify(toast, 'ok', `deleted ${target.name}`)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'name', header: 'Domain', width: '40%' },
{ key: 'action', header: 'Action', width: '16%' },
{ key: 'source', header: 'List', width: '20%' },
{ key: 'hits', header: 'Hits', width: '10%', align: 'right' as const },
{ key: 'updated', header: 'Updated', width: '10%' },
{ key: 'delete', header: '', width: '4%', align: 'right' as const },
]
const rows: Row[] = items.map((item, i) => ({
__key: `${item.name}-${item.action}-${item.list}-${i}`,
name: <span className="block max-w-[320px] truncate font-mono text-xs">{item.name}</span>,
action: <Badge tone={actionTone(String(item.action))}>{item.action}</Badge>,
source: (
<span className="block max-w-[200px] truncate text-xs text-slate-500 dark:text-slate-400">
{item.list || item.source || '—'}
</span>
),
hits: <span className="font-mono text-xs">{formatNumber(item.hits)}</span>,
updated: <span className="whitespace-nowrap text-xs">{formatRelative(item.updated_at)}</span>,
delete: (
<IconButton
icon={<Trash2 size={14} />}
label={`delete ${item.name}`}
variant="ghost"
onClick={() => setPendingDelete(item)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Domains"
description="Inline allow and block rules, checked before the downloaded lists."
actions={
<Button icon={<Plus size={14} />} onClick={() => setAddOpen(true)}>
Add rule
</Button>
}
/>
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input
label="Search"
placeholder="ads.example.com"
icon={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Action"
value={action}
onChange={(e) => setAction(e.target.value)}
options={[{ value: '', label: 'All actions' }, ...ACTIONS.map((a) => ({ value: a.value, label: a.label }))]}
/>
<Select
label="List"
value={list}
onChange={(e) => setList(e.target.value)}
options={[{ value: '', label: 'All lists' }, ...lists.map((l) => ({ value: l, label: l }))]}
/>
</div>
</Card>
<Card
title="Rules"
subtitle={`${formatNumber(total)} matching`}
padded={false}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading}
empty={
<EmptyState
title="No rules match"
description="Change the filters, or add a rule with the button above."
/>
}
/>
</div>
<div className="border-t border-slate-200 p-3 dark:border-slate-800">
<Pagination page={page} perPage={PER_PAGE} total={total} onPage={setPage} />
</div>
</Card>
<Dialog
open={addOpen}
onClose={() => setAddOpen(false)}
title="Add a rule"
description="Rules are checked before the blocklists, so an allow rule wins over a block."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setAddOpen(false)} disabled={add.pending}>
Cancel
</Button>
<Button onClick={() => void submit()} loading={add.pending} disabled={!canSubmit}>
{bulkLines.length > 0 ? `Add ${bulkLines.length} rules` : 'Add rule'}
</Button>
</div>
}
>
<div className="space-y-3">
{formError ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{formError}
</p>
) : null}
<Input
label="Domain"
placeholder="ads.example.com"
hint={NAME_HINT}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={form.advanced}
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Action"
value={form.action}
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
/>
<Input
label="List"
placeholder="inline"
hint="Optional group name, defaults to the inline list."
value={form.list}
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
autoComplete="off"
/>
</div>
<button
type="button"
className="text-xs font-medium text-sky-600 underline-offset-2 hover:underline dark:text-sky-400"
onClick={() => setForm((f) => ({ ...f, advanced: !f.advanced }))}
>
{form.advanced ? 'Hide bulk paste' : 'Bulk paste'}
</button>
{form.advanced ? (
<Textarea
label="Rules"
hint={`One per line; a leading ! makes it an allow rule. ${bulkLines.length} line${bulkLines.length === 1 ? '' : 's'} parsed.`}
monospace
rows={8}
value={form.bulk}
onChange={(e) => setForm((f) => ({ ...f, bulk: e.target.value }))}
spellCheck={false}
/>
) : null}
</div>
</Dialog>
<ConfirmDialog
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={() => void confirmDelete()}
title="Delete this rule?"
message={
pendingDelete
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
: ''
}
confirmLabel="Delete"
destructive
pending={remove.pending}
/>
</div>
)
}
+584
View File
@@ -0,0 +1,584 @@
/**
* Upstreams — the resolver pool.
*
* Server order is meaningful under the `failover` policy, so the move up/down
* buttons edit a local draft that is only written on Save. Nothing here talks
* to the resolver directly: the PUT is validated by the daemon and swapped in
* atomically, or rejected with a message.
*/
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Gauge,
Pencil,
Plus,
RotateCw,
Save,
Trash2,
} from 'lucide-react'
import { getUpstreams, putUpstreams } from '../../lib/api'
import type { UpstreamBootstrap, UpstreamServer } from '../../lib/types'
import { useApi, useAsyncAction } from '../../hooks/useApi'
import { formatLatency, formatMs, formatNumber, formatRate } from '../../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Progress,
Select,
Switch,
Table,
Textarea,
useToast,
} from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
type ToastLike = {
success?: (message: string) => void
error?: (message: string) => void
toast?: (message: string) => void
push?: (message: string) => void
}
function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void {
const t = toast as ToastLike
const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push)
fn?.call(t, message)
}
const POLICIES = [
{ value: 'parallel', label: 'Parallel — query all, take the first answer' },
{ value: 'random', label: 'Random — weighted pick' },
{ value: 'failover', label: 'Failover — in order, skip the unhealthy' },
]
const PROTOS = [
{ value: 'udp', label: 'UDP' },
{ value: 'tcp', label: 'TCP' },
{ value: 'tls', label: 'TLS (DoT)' },
]
/** "30s", "1m30s", "1h" or a bare number -> seconds. */
function parseDuration(value: string): number {
const text = value.trim().toLowerCase()
if (text === '') return 30
if (/^\d+(\.\d+)?$/.test(text)) return Number.parseFloat(text)
let total = 0
let matched = false
const re = /(\d+(?:\.\d+)?)\s*(ms|s|m|h)/g
let m = re.exec(text)
while (m !== null) {
matched = true
const n = Number.parseFloat(m[1])
if (m[2] === 'ms') total += n / 1000
else if (m[2] === 's') total += n
else if (m[2] === 'm') total += n * 60
else total += n * 3600
m = re.exec(text)
}
return matched && total > 0 ? Math.round(total) : 30
}
/** What the user edits: runtime fields ride along untouched. */
type ServerDraft = {
addr: string
proto: string
server_name: string
insecure_skip_verify: boolean
weight: number
comment: string
health: string
latency_ms: number
fail_rate: number
breaker_open: boolean
}
type BootstrapDraft = { name: string; addresses: string; refresh: string }
type Draft = {
policy: string
max_attempts: string
breaker_threshold: string
healthcheck_s: string
servers: ServerDraft[]
bootstraps: BootstrapDraft[]
}
function toServerDraft(s: UpstreamServer): ServerDraft {
return {
addr: s.addr ?? '',
proto: String(s.proto ?? 'udp'),
server_name: s.server_name ?? '',
insecure_skip_verify: false,
weight: typeof s.weight === 'number' ? s.weight : 1,
comment: s.comment ?? '',
health: String(s.health ?? 'down'),
latency_ms: s.latency_ms ?? 0,
fail_rate: s.fail_rate ?? 0,
breaker_open: s.breaker_open === true,
}
}
function healthTone(health: string): 'default' | 'ok' | 'warn' | 'danger' {
if (health === 'up') return 'ok'
if (health === 'degraded') return 'warn'
return 'danger'
}
const EMPTY_SERVER: ServerDraft = {
addr: '',
proto: 'udp',
server_name: '',
insecure_skip_verify: false,
weight: 1,
comment: '',
health: 'down',
latency_ms: 0,
fail_rate: 0,
breaker_open: false,
}
function toInt(value: string, fallback: number): number {
const n = Number.parseInt(value.trim(), 10)
return Number.isFinite(n) && n >= 0 ? n : fallback
}
function toFloat(value: string, fallback: number): number {
const n = Number.parseFloat(value.trim())
return Number.isFinite(n) && n >= 0 ? n : fallback
}
function splitList(value: string): string[] {
return value
.split(/[,\s]+/)
.map((v) => v.trim())
.filter((v) => v !== '')
}
export default function Upstreams() {
const toast = useToast()
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
const [draft, setDraft] = useState<Draft | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<ServerDraft | null>(null)
const [pendingRemove, setPendingRemove] = useState<number | null>(null)
const servers: UpstreamServer[] = useMemo(() => data?.servers ?? [], [data])
const bootstraps: UpstreamBootstrap[] = useMemo(() => data?.bootstraps ?? [], [data])
const working: Draft = useMemo(() => {
if (draft) return draft
return {
policy: String(data?.policy ?? 'parallel'),
max_attempts: String(data?.max_attempts ?? 2),
breaker_threshold: String(data?.breaker_threshold ?? 5),
healthcheck_s: String(parseDuration(String(data?.healthcheck_interval ?? '30s'))),
servers: servers.map(toServerDraft),
bootstraps: bootstraps.map((b) => ({
name: b.name ?? '',
addresses: (b.addresses ?? []).join(', '),
refresh: b.refresh ?? '',
})),
}
}, [draft, data, servers, bootstraps])
const dirty = draft !== null
const slowest = working.servers.reduce((max, s) => Math.max(max, s.latency_ms || 0), 0)
const save = useAsyncAction(async () => {
const payload = {
policy: working.policy,
max_attempts: toInt(working.max_attempts, 2),
breaker_threshold: toInt(working.breaker_threshold, 5),
healthcheck_interval_s: toInt(working.healthcheck_s, 30),
servers: working.servers.map((s) => ({
addr: s.addr.trim(),
proto: s.proto,
server_name: s.server_name.trim(),
insecure_skip_verify: s.insecure_skip_verify,
weight: s.weight,
comment: s.comment,
health: s.health,
latency_ms: s.latency_ms,
fail_rate: s.fail_rate,
breaker_open: s.breaker_open,
})),
bootstraps: working.bootstraps.map((b) => ({
name: b.name.trim(),
addresses: splitList(b.addresses),
refresh: b.refresh.trim(),
})),
}
return putUpstreams(payload)
})
const commit = async (): Promise<void> => {
if (working.servers.some((s) => s.addr.trim() === '')) {
notify(toast, 'fail', 'every upstream needs an address')
return
}
const res = await save.run()
if (res === null) {
notify(toast, 'fail', save.error ?? 'the server rejected the upstream configuration')
return
}
setDraft(null)
notify(toast, 'ok', 'upstream configuration saved')
refetch()
}
const move = (index: number, delta: number): void => {
const next = [...working.servers]
const target = index + delta
if (target < 0 || target >= next.length) return
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
setDraft({ ...working, servers: next })
}
const setServer = (index: number, patch: Partial<ServerDraft>): void => {
const next = [...working.servers]
const current = next[index]
if (!current) return
next[index] = { ...current, ...patch }
setDraft({ ...working, servers: next })
}
const setBootstrap = (index: number, patch: Partial<BootstrapDraft>): void => {
const next = [...working.bootstraps]
const current = next[index]
if (!current) return
next[index] = { ...current, ...patch }
setDraft({ ...working, bootstraps: next })
}
const removeServer = async (): Promise<void> => {
const index = pendingRemove
if (index === null) return
const next = working.servers.filter((_, i) => i !== index)
setDraft({ ...working, servers: next })
setPendingRemove(null)
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
{ key: 'addr', header: 'Address', width: '22%' },
{ key: 'proto', header: 'Proto', width: '7%' },
{ key: 'server_name', header: 'SNI', width: '14%' },
{ key: 'health', header: 'Health', width: '12%' },
{ key: 'latency', header: 'Latency', width: '17%' },
{ key: 'fail', header: 'Fail', width: '8%', align: 'right' as const },
{ key: 'weight', header: 'Weight', width: '7%', align: 'right' as const },
{ key: 'comment', header: 'Comment', width: '9%' },
{ key: 'order', header: 'Order', width: '4%' },
]
const rows: Row[] = working.servers.map((s, i) => ({
__key: `${s.addr}-${i}`,
addr: <span className="font-mono text-xs">{s.addr || '(empty)'}</span>,
proto: <Badge tone="muted">{s.proto}</Badge>,
server_name: <span className="font-mono text-xs">{s.server_name || '—'}</span>,
health: (
<span className="flex flex-wrap items-center gap-1">
<Badge tone={healthTone(s.health)}>{s.health}</Badge>
{s.breaker_open ? <Badge tone="danger">breaker</Badge> : null}
</span>
),
latency: (
<span className="flex items-center gap-2">
<span className="w-10 shrink-0 font-mono text-xs text-slate-500">{formatLatency(s.latency_ms)}</span>
<span className="w-full min-w-[48px]">
<Progress
value={s.latency_ms}
max={slowest || 1}
tone={s.health === 'up' ? 'ok' : s.health === 'degraded' ? 'warn' : 'danger'}
/>
</span>
</span>
),
fail: <span className="font-mono text-xs">{s.fail_rate > 0 ? formatRate(s.fail_rate) : '—'}</span>,
weight: <span className="font-mono text-xs">{s.weight}</span>,
comment: <span className="block max-w-[160px] truncate text-xs text-slate-500">{s.comment}</span>,
order: (
<span className="flex justify-end gap-0.5">
<IconButton icon={<ArrowUp size={13} />} label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} />
<IconButton icon={<ArrowDown size={13} />} label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} />
<IconButton icon={<Pencil size={13} />} label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} />
<IconButton icon={<Trash2 size={13} />} label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} />
</span>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Upstreams"
description="Where queries go, and what happens when a server stops answering."
actions={
<div className="flex items-center gap-2">
<Button
variant="outline"
icon={<RotateCw size={14} />}
onClick={() => setDraft(null)}
disabled={!dirty}
>
Discard
</Button>
<Button icon={<Save size={14} />} onClick={() => void commit()} loading={save.pending} disabled={!dirty}>
Save
</Button>
</div>
}
/>
<Card title="Pool behaviour">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Select
label="Policy"
value={working.policy}
onChange={(e) => setDraft({ ...working, policy: e.target.value })}
options={POLICIES}
hint="Failover uses the order below."
/>
<Input
label="Max attempts"
type="number"
min={1}
value={working.max_attempts}
onChange={(e) => setDraft({ ...working, max_attempts: e.target.value })}
hint="How many servers to try per query."
/>
<Input
label="Breaker threshold"
type="number"
min={1}
value={working.breaker_threshold}
onChange={(e) => setDraft({ ...working, breaker_threshold: e.target.value })}
hint="Consecutive failures before the breaker opens."
/>
<Input
label="Health check interval (s)"
type="number"
min={1}
value={working.healthcheck_s}
onChange={(e) => setDraft({ ...working, healthcheck_s: e.target.value })}
hint="Probes run this often."
/>
</div>
</Card>
<Card
title="Servers"
subtitle={`${working.servers.length} configured`}
actions={
<Button
size="sm"
icon={<Plus size={14} />}
onClick={() => {
setEditing(EMPTY_SERVER)
setEditorOpen(true)
}}
>
Add server
</Button>
}
padded={false}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
loading={loading && working.servers.length === 0}
empty={<EmptyState title="No upstreams" description="Add at least one resolver or queries will fail." />}
/>
</div>
</Card>
<Card
title="Bootstraps"
subtitle="Name servers used to resolve the upstream hostnames themselves"
actions={
<Button
size="sm"
icon={<Plus size={14} />}
onClick={() =>
setDraft({
...working,
bootstraps: [...working.bootstraps, { name: '', addresses: '', refresh: '' }],
})
}
>
Add bootstrap
</Button>
}
>
{working.bootstraps.length === 0 ? (
<EmptyState
icon={<Gauge size={20} />}
title="No bootstrap resolvers"
description="Without them, an upstream address must already be an IP."
/>
) : (
<div className="space-y-3">
{working.bootstraps.map((b, i) => (
<div key={`${b.name}-${i}`} className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input
label="Name"
value={b.name}
onChange={(e) => setBootstrap(i, { name: e.target.value })}
placeholder="root servers"
/>
<Input
label="Addresses"
value={b.addresses}
onChange={(e) => setBootstrap(i, { addresses: e.target.value })}
placeholder="198.41.0.4, 170.247.170.2"
hint="Comma separated."
/>
<div className="flex items-end gap-2">
<Input
label="Refresh"
value={b.refresh}
onChange={(e) => setBootstrap(i, { refresh: e.target.value })}
placeholder="24h"
/>
<IconButton
icon={<Trash2 size={14} />}
label={`remove bootstrap ${b.name}`}
variant="ghost"
onClick={() =>
setDraft({
...working,
bootstraps: working.bootstraps.filter((_, j) => j !== i),
})
}
/>
</div>
</div>
))}
</div>
)}
</Card>
<p className="text-xs text-slate-500 dark:text-slate-400">
{working.servers.length} server{working.servers.length === 1 ? '' : 's'} ·{' '}
{formatNumber(working.servers.reduce((sum, s) => sum + s.weight, 0))} total weight ·
slowest {formatMs(slowest)}
{dirty ? ' · unsaved changes' : ''}
</p>
<Dialog
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editing && working.servers.some((s) => s.addr === editing.addr) ? 'Edit server' : 'Add server'}
description="An address is host:port; the SNI name is what TLS upstreams are verified against."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setEditorOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
if (!editing) return
const index = working.servers.findIndex((s) => s.addr === editing.addr && s !== editing)
if (index === -1) {
setDraft({ ...working, servers: [...working.servers, editing] })
} else {
setServer(index, editing)
}
setEditorOpen(false)
notify(toast, 'ok', 'server updated — remember to save')
}}
disabled={!editing || editing.addr.trim() === ''}
>
Apply
</Button>
</div>
}
>
{editing ? (
<div className="space-y-3">
<Input
label="Address"
value={editing.addr}
onChange={(e) => setEditing({ ...editing, addr: e.target.value })}
placeholder="9.9.9.9:53"
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Protocol"
value={editing.proto}
onChange={(e) => setEditing({ ...editing, proto: e.target.value })}
options={PROTOS}
/>
<Input
label="Weight"
type="number"
min={0}
step="0.1"
value={String(editing.weight)}
onChange={(e) => setEditing({ ...editing, weight: toFloat(e.target.value, 1) })}
hint="Relative share for the random policy."
/>
</div>
<Input
label="Server name (SNI)"
value={editing.server_name}
onChange={(e) => setEditing({ ...editing, server_name: e.target.value })}
placeholder="dns.quad9.net"
autoComplete="off"
spellCheck={false}
/>
<Input
label="Comment"
value={editing.comment}
onChange={(e) => setEditing({ ...editing, comment: e.target.value })}
/>
<Textarea
label="Notes"
hidden
onChange={() => undefined}
value=""
/>
<Switch
checked={editing.insecure_skip_verify}
onChange={(next) => setEditing({ ...editing, insecure_skip_verify: next })}
label="Skip TLS verification"
description="Accept any certificate. Only for a self-signed resolver you control."
/>
</div>
) : null}
</Dialog>
<ConfirmDialog
open={pendingRemove !== null}
onClose={() => setPendingRemove(null)}
onConfirm={() => void removeServer()}
title="Remove this upstream?"
message={
pendingRemove !== null && working.servers[pendingRemove]
? `${working.servers[pendingRemove].addr} will be dropped from the pool. Save to make it permanent.`
: ''
}
confirmLabel="Remove"
destructive
/>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+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"]
}
+26
View File
@@ -0,0 +1,26 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
// The Go binary serves the built assets and the API from the same origin, so
// the app always talks to a relative `/api` base. During development Vite
// proxies that prefix to a locally running fancy-dns.
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
// The logs SSE stream is long-lived: never time it out.
timeout: 0,
proxyTimeout: 0,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})