initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
Binary file not shown.
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2488
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user