feat: реализовать EvoFirewall V1 control plane
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-20 19:50:54 +07:00
co-authored by Cursor
parent d71b45d86f
commit ebadf70e2b
107 changed files with 15196 additions and 99 deletions
+2 -1
View File
@@ -8,13 +8,14 @@ AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
JWT_TTL_HOURS=24
PUBLIC_BASE_URL=http://localhost:8080
EVOFW_ENROLL_SEED=dev-enroll-seed-change-me
# Frontend (Vite) — apps/web/.env.local
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=http://localhost:5175
# ReUI PRO (apps/web/components.json → @reui Authorization)
# Ключ: https://reui.io/docs/license-setup — класть в apps/web/.env.local (gitignored)
REUI_LICENSE_KEY=
# EvoBGP integration (source of prefix lists)
+95
View File
@@ -0,0 +1,95 @@
name: Build and Push EvoFirewall Docker Image
on:
push:
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
tags: ['v*']
paths: ['**']
pull_request:
branches: [main, develop]
paths: ['**']
jobs:
build-and-push:
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Create version file
run: |
VERSION=$(cat VERSION)
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
BRANCH="${{ gitea.ref_name }}"
echo "APP_VERSION=${VERSION}" > ./version.txt
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.shts.su/${{ gitea.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NODE_VERSION=22.23.0-bookworm-slim
cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache
cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max
provenance: true
create-release:
needs: build-and-push
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${LAST_TAG}..HEAD)
else
CHANGELOG=$(git log --pretty=format:"- %s (%h)" -n 20)
fi
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Gitea Release
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
"https://git.shts.su/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\",\"body\":$(echo '${{ steps.changelog.outputs.CHANGELOG }}' | jq -Rs .)}"
+18 -23
View File
@@ -4,36 +4,31 @@
EvoFirewall — централизованный firewall controller (Linux / MikroTik agents, blocklists, статистика). Интеграции: **auth-portal** (app id `fw`), **EvoBGP** (источник префиксов).
## Стек (план / scaffold)
## Стек
- Monorepo: pnpm + turbo (`apps/*`, `packages/*`)
- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI `@reui`
- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI Frame
- `apps/api` — Fastify + Drizzle + SQLite
- Packages: `@evofw/ui`, `@evofw/shared`, `@evofw/db`
## ReUI PRO (обязательно)
| Что | Где |
|-----|-----|
| MCP | `user-reui` → https://mcp.reui.io/api/mcp (см. `.mcp.json`, `.cursor/mcp.json`) |
| Skill | `.agents/skills/reui` (v`42d70dcc3d`) — update: `curl -fsSL https://mcp.reui.io/install \| node -` |
| shadcn skill | `.agents/skills/shadcn` |
| Cursor rules | `.cursor/rules/reui-mcp.mdc`, `reui.mdc`, `shadcn-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-*.mdc` |
| Design contract | [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — **surface: frame** |
| License | `apps/web/.env.local``REUI_LICENSE_KEY` |
| Registry | `apps/web/components.json``@reui` + Bearer |
Docs: [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [License](https://reui.io/docs/license-setup) · [Agent skills](https://reui.io/docs/agent-skills)
UI workflow: `user-reui` search/compose → cite `previewUrl` → CLI из `apps/web` → adapt в `reui-kit``validate_usage`.
## Структура
## Команды
```bash
pnpm install
pnpm --filter @evofw/shared build
pnpm --filter @evofw/db build
pnpm --filter @evofw/api dev
pnpm --filter @evofw/web dev
pnpm --filter @evofw/web build
```
apps/web/src/components/reui/ # CLI @reui/*
apps/web/src/components/reui-kit/ # ResourcePage, KpiStatGrid, …
packages/ui/ # shadcn primitives only
```
## ReUI PRO
Surface: **frame**. Contract: `docs/ui-design-contract.md`. MCP `user-reui` + CLI из `apps/web`.
## Docs
`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`.
## Git
+53
View File
@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22.23.0-bookworm-slim
FROM node:${NODE_VERSION} AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
RUN pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
&& pnpm --filter @evofw/api deploy --prod /out \
&& cp -r apps/web/dist /out/static \
&& mkdir -p /out/dist/agent-scripts \
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo
FROM node:${NODE_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080
COPY --from=build /out ./
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
+19 -44
View File
@@ -1,67 +1,42 @@
# EvoFirewall
Централизованный firewall controller: Linux / MikroTik agents, blocklists, статистика.
Централизованный firewall controller: Linux / MikroTik agents, IP lists, allow/deny политики, статистика.
Интеграции (план): **auth-portal** (SSO, app id `fw`), **EvoBGP** (источник префиксов).
Интеграции: **auth-portal** (SSO, app id `fw`), **EvoBGP** (источник префиксов).
## Стек (как CFDM / VPS Tracker)
## Стек
- Monorepo: pnpm workspaces + turbo
- `apps/web` — Vite + React + TanStack Router/Query + shadcn/ui + ReUI
- `apps/api` — Fastify + Drizzle + SQLite
- `packages/ui` `@evofw/ui` (shadcn primitives)
- `packages/shared` — Zod-контракты
- `packages/db` — схема и репозитории
## Структура
```
apps/
web/ # SPA
api/ # HTTP API
packages/
ui/ # shadcn CLI output
shared/ # contracts
db/ # drizzle
docs/
deploy/
scripts/
data/ # SQLite (gitignored)
```
- `apps/web` — Vite + React + TanStack + shadcn/ui + ReUI Frame
- `apps/api` — Fastify 5 + Drizzle + SQLite
- Packages: `@evofw/ui`, `@evofw/shared`, `@evofw/db`
## Быстрый старт
```bash
pnpm install
cp .env.example .env
# ReUI PRO: apps/web/.env.local уже с ключом (из CFDM) или скопируйте:
# copy ..\cloudflare-domain-manager\apps\web\.env.local apps\web\.env.local
pnpm --filter @evofw/db build
pnpm --filter @evofw/shared build
pnpm --filter @evofw/api dev # :8080
pnpm --filter @evofw/web dev # :5177
```
Scaffold пока без Vite/Fastify — следующий шаг: init web/api по образцу CFDM.
## ReUI PRO
Skill + MCP установлены официальным инсталлером (`https://mcp.reui.io/install`):
- Skill: `.agents/skills/reui` (версия `42d70dcc3d`)
- MCP: `.mcp.json` / `.cursor/mcp.json``https://mcp.reui.io/api/mcp`
- Rules: `.cursor/rules/reui-mcp.mdc`, `reui.mdc`, `shadcn-*.mdc`, `frontend-*.mdc`
- Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface **frame**
Linux agent:
```bash
cd apps/web
# нужен REUI_LICENSE_KEY в .env.local (уже из CFDM)
pnpm dlx shadcn@latest add @reui/frame --yes
curl -fsSL http://localhost:8080/v1/agent/install.sh | \
EVOFW_CP_URL=http://localhost:8080 \
EVOFW_SEED=dev-enroll-seed-change-me \
EVOFW_CLIENT_NAME="web-01" \
bash
```
Обновить skill/MCP:
Затем Approve в UI `/agents`.
```bash
curl -fsSL https://mcp.reui.io/install | node -
```
## Docs
Docs: https://reui.io/docs/license-setup · https://reui.io/llms.txt · https://reui.io/docs/mcp · https://reui.io/docs/agent-skills
См. [`docs/README.md`](docs/README.md).
## Git
+1
View File
@@ -0,0 +1 @@
0.1.0
+28 -5
View File
@@ -1,12 +1,35 @@
{
"name": "@evofw/api",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "echo \"api: scaffold — Fastify app not initialized yet\"",
"build": "echo \"api: scaffold — skip\"",
"lint": "echo \"api: scaffold — skip\"",
"test": "echo \"api: scaffold — skip\""
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js",
"test": "vitest run"
},
"dependencies": {
"@evofw/db": "workspace:*",
"@evofw/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/schedule": "^6.0.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"toad-scheduler": "^3.0.1",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# EvoFirewall Linux sync agent — nft / ipset / iptables
set -euo pipefail
CONF_FILE=/etc/evofw/agent.conf
LOG_FILE=/var/log/evofw-firewall.log
STATE_DIR=/var/lib/evofw
HASH_FILE="${STATE_DIR}/last_hash"
POLICY_FILE="${STATE_DIR}/last_policy.json"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
if [[ ! -f "$CONF_FILE" ]]; then
log "missing $CONF_FILE"
exit 1
fi
# shellcheck disable=SC1090
source "$CONF_FILE"
: "${EVOFW_CP_URL:?}"
: "${CLIENT_TOKEN:?}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
BACKEND="${KERNEL_BACKEND:-auto}"
mkdir -p "$STATE_DIR"
curl_policy() {
local dest="$1"
local code
code=$(curl -sS -o "$dest" -w "%{http_code}" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Accept: application/json" \
"${EVOFW_CP_URL%/}/v1/agent/policy") || return 1
if [[ "$code" == "403" ]]; then
log "pending approval"
return 2
fi
if [[ "$code" != "200" ]]; then
log "policy HTTP $code"
return 1
fi
return 0
}
if ! curl_policy "$POLICY_FILE"; then
rc=$?
[[ "$rc" == "2" ]] && exit 0
exit 1
fi
parse_policy() {
local f="$1"
if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f")
MODE=$(jq -r '.policy_mode // "blacklist"' "$f")
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
return 0
fi
if command -v python3 >/dev/null 2>&1; then
eval "$(python3 - "$f" <<'PY'
import json,sys
d=json.load(open(sys.argv[1],encoding="utf-8"))
print(f'HASH={d.get("hash") or ""}')
print(f'MODE={d.get("policy_mode") or "blacklist"}')
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
PY
)"
return 0
fi
log "need jq or python3"
exit 1
}
HASH=""; MODE=blacklist; DENY=(); ALLOW=()
parse_policy "$POLICY_FILE"
log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
PACKETS_DROPPED=0
PACKETS_ACCEPTED=0
KERNEL_METHOD=""
APPLIED=0
nft_join() {
local out="" p
for p in "$@"; do
[[ -n "$out" ]] && out+=", "
out+="$p"
done
printf '%s' "$out"
}
nft_add_chunk() {
local table=$1 name=$2 setname=$3
shift 3
local joined; joined=$(nft_join "$@")
nft add element "$table" "$name" "$setname" "{ ${joined} }" 2>>"$LOG_FILE" || {
for p in "$@"; do nft add element "$table" "$name" "$setname" "{ $p }" 2>>"$LOG_FILE" || true; done
}
}
collect_nft_stats() {
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
local line
while IFS= read -r line; do
if [[ "$line" == *drop* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then
PACKETS_DROPPED="${BASH_REMATCH[1]}"
elif [[ "$line" == *accept* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then
PACKETS_ACCEPTED="${BASH_REMATCH[1]}"
fi
done < <(nft list chain inet evofw input 2>/dev/null || true)
}
apply_nft() {
local table=inet name=evofw
local deny_v4=() allow_v4=() p
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }'
nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \
nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }'
nft flush set "$table" "$name" deny_v4
nft flush set "$table" "$name" allow_v4
local batch=() chunk=64
for p in "${deny_v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"; batch=(); fi
done
((${#batch[@]})) && nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"
batch=()
for p in "${allow_v4[@]}"; do
batch+=("$p")
if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"; batch=(); fi
done
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
nft delete chain "$table" "$name" input 2>/dev/null || true
if [[ "$MODE" == "whitelist" ]]; then
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
nft add rule "$table" "$name" input ct state established,related counter accept
nft add rule "$table" "$name" input iif lo counter accept
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
nft add rule "$table" "$name" input counter drop
else
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
nft add rule "$table" "$name" input counter accept
fi
KERNEL_METHOD=nft
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
}
apply_ipset() {
local dset=evofw_deny_v4 aset=evofw_allow_v4
ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet
ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet
ipset flush "$dset"; ipset flush "$aset"
local p n=0
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done
for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done
iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
if [[ "$MODE" == "whitelist" ]]; then
iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT
iptables -A INPUT -j DROP 2>/dev/null || true
else
iptables -I INPUT -m set --match-set "$dset" src -j DROP
fi
KERNEL_METHOD=ipset
APPLIED=$n
}
send_report() {
if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then
collect_nft_stats
fi
local report
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}")
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$report" >/dev/null 2>&1 || true
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"agent"}' >/dev/null 2>&1 || true
}
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip apply"
KERNEL_METHOD="${BACKEND}"
send_report
exit 0
fi
case "$BACKEND" in
nft|auto)
if command -v nft >/dev/null 2>&1; then apply_nft
elif command -v ipset >/dev/null 2>&1; then apply_ipset
else log "no backend"; exit 1; fi
;;
ipset) apply_ipset ;;
*) apply_nft ;;
esac
echo "$HASH" >"$HASH_FILE"
log "applied mode=$MODE count=$APPLIED method=$KERNEL_METHOD"
send_report
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# EvoFirewall Linux install one-liner
set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
echo "evofw install: run as root" >&2
exit 1
fi
for cmd in curl bash; do
command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; }
done
if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq jq || true
fi
fi
: "${EVOFW_CP_URL:?EVOFW_CP_URL required}"
: "${EVOFW_SEED:?EVOFW_SEED required}"
: "${EVOFW_CLIENT_NAME:?EVOFW_CLIENT_NAME required}"
CONF_DIR=/etc/evofw
CONF_FILE="${CONF_DIR}/agent.conf"
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
PLATFORM="${EVOFW_PLATFORM:-linux}"
if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
echo "Already installed ($CONF_FILE). Set EVOFW_INSTALL_FORCE=1 to reinstall." >&2
exit 1
fi
gen_token() {
if command -v openssl >/dev/null 2>&1; then
echo -n "evofw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"
else
echo -n "evofw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
fi
}
CLIENT_TOKEN="$(gen_token)"
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
CP_URL="${EVOFW_CP_URL%/}"
ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
ENROLL_TMP=$(mktemp)
trap 'rm -f "$ENROLL_TMP"' EXIT
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \
-H "Content-Type: application/json" \
-H "X-EvoFW-Seed: ${EVOFW_SEED}" \
-d "$ENROLL_BODY")
if [[ "$ENROLL_CODE" != "201" && "$ENROLL_CODE" != "200" ]]; then
echo "enroll failed: HTTP ${ENROLL_CODE}" >&2
cat "$ENROLL_TMP" >&2
exit 1
fi
RESP=$(cat "$ENROLL_TMP")
CLIENT_ID=""
if command -v jq >/dev/null 2>&1; then
CLIENT_ID=$(echo "$RESP" | jq -r '.client_id // .id')
else
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
fi
mkdir -p "$CONF_DIR"
chmod 700 "$CONF_DIR"
cat >"$CONF_FILE" <<EOF
EVOFW_CP_URL=${CP_URL}
CLIENT_ID=${CLIENT_ID}
CLIENT_TOKEN=${CLIENT_TOKEN}
CLIENT_NAME=${EVOFW_CLIENT_NAME}
KERNEL_BACKEND=auto
EOF
chmod 600 "$CONF_FILE"
curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_SCRIPT"
chmod 755 "$SYNC_SCRIPT"
if command -v nft >/dev/null 2>&1; then
BACKEND=nft
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
BACKEND=ipset
elif command -v iptables >/dev/null 2>&1; then
BACKEND=iptables
else
echo "no supported firewall backend" >&2
exit 1
fi
sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \
echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE"
INTERVAL="${EVOFW_SYNC_INTERVAL:-1min}"
if command -v systemctl >/dev/null 2>&1; then
cat >/etc/systemd/system/evofw-firewall.service <<'UNIT'
[Unit]
Description=EvoFirewall sync
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/evofw-firewall.sh
UNIT
cat >/etc/systemd/system/evofw-firewall.timer <<UNIT
[Unit]
Description=EvoFirewall sync timer
[Timer]
OnBootSec=30s
OnUnitActiveSec=${INTERVAL}
AccuracySec=5s
Unit=evofw-firewall.service
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evofw-firewall.timer
else
(crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
fi
echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI, then: $SYNC_SCRIPT"
@@ -0,0 +1,46 @@
# EvoFirewall MikroTik install (RouterOS 7+)
# Usage: import after setting globals, or paste into terminal.
# Required globals before import (or edit below):
# :global EvofwCpUrl "https://fw.example.com"
# :global EvofwSeed "YOUR_SEED"
# :global EvofwName "mt-01"
:global EvofwCpUrl
:global EvofwSeed
:global EvofwName
:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" }
:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" }
:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] }
:local token ("evofw_" . [/certificate scep-server nonce generate])
:if ([:len $token] < 20) do={
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]])
}
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}")
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
# Persist credentials for scheduler script
/system script remove [find name="evofw-env"]
/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
/system script remove [find name="evofw-sync"]
/system script add name=evofw-sync policy=read,write,policy,test source={
:global EvofwCpUrl
:global EvofwToken
:if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env }
:local tmp [/file get [find name="evofw-policy.json"] name]
/tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json
# Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once:
# /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw
# whitelist: policy drop + accept EVOFW_ALLOW
:log info "evofw: policy fetched — apply address-lists via controller export or manual parse"
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
}
/system scheduler remove [find name="evofw-sync"]
/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists")
+96
View File
@@ -0,0 +1,96 @@
import { resolve } from 'node:path'
import Fastify from 'fastify'
import {
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from '@fastify/type-provider-zod'
import { AsyncTask, CronJob } from 'toad-scheduler'
import type { AppConfig } from './config.js'
import { loadConfig } from './config.js'
import authPlugin from './plugins/auth.js'
import corsPlugin from './plugins/cors.js'
import dbPlugin from './plugins/db.js'
import errorHandlerPlugin from './plugins/error-handler.js'
import { healthRoutes } from './routes/health.js'
import { controlRoutes } from './routes/control.js'
import { agentRoutes } from './routes/agent.js'
import { refreshAllLists } from './services/lists/refresh.js'
import { repos } from '@evofw/db'
export interface BuildAppOptions {
config?: AppConfig
memory?: boolean
}
export async function buildApp(opts: BuildAppOptions = {}) {
const config = opts.config ?? loadConfig()
const app = Fastify({
logger: { level: config.logLevel },
}).withTypeProvider<ZodTypeProvider>()
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(import('@fastify/sensible'))
await app.register(import('@fastify/helmet'), {
contentSecurityPolicy: false,
})
await app.register(import('@fastify/rate-limit'), {
max: 300,
timeWindow: '1 minute',
})
await app.register(corsPlugin)
await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config })
// Seed enroll_seed into settings if empty
if (!repos.getSetting(app.db, 'enroll_seed')) {
repos.setSetting(app.db, 'enroll_seed', config.enrollSeed)
}
await app.register(healthRoutes)
await app.register(agentRoutes, { config })
await app.register(
async (protectedApi) => {
protectedApi.addHook('onRequest', app.requireAuth)
await protectedApi.register(controlRoutes, { config })
},
{ prefix: '/api/v1' },
)
const staticDir = config.staticDir ?? resolve(process.cwd(), 'static')
if (config.staticDir !== null) {
await app.register(import('@fastify/static'), {
root: staticDir,
wildcard: false,
})
app.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile('index.html')
})
}
if (!opts.memory) {
await app.register(import('@fastify/schedule'))
const task = new AsyncTask(
'list-refresh',
async () => {
await refreshAllLists(app.db)
app.log.info('list refresh completed')
},
(err) => {
app.log.warn({ err }, 'list refresh failed')
},
)
app.scheduler.addCronJob(
new CronJob({ cronExpression: '0 */5 * * * *' }, task, {
preventOverrun: true,
}),
)
}
return app
}
+55
View File
@@ -0,0 +1,55 @@
import { resolve } from 'node:path'
export interface AppConfig {
databaseUrl: string
jwtSecret: string
jwtTtlHours: number
serverPort: number
staticDir: string | null
logLevel: string
authRequired: boolean
authIssuer: string
authPortalUrl: string
publicBaseUrl: string
enrollSeed: string
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production'
const jwtSecret =
process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me')
return {
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret: jwtSecret || 'dev-secret-change-me',
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
staticDir: process.env.STATIC_DIR
? resolve(process.env.STATIC_DIR)
: null,
logLevel: process.env.LOG_LEVEL ?? 'info',
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer:
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? 'https://auth.shnt.top',
authPortalUrl: (
process.env.AUTH_PORTAL_URL ??
process.env.VITE_AUTH_PORTAL_URL ??
'http://localhost:5175'
).replace(/\/$/, ''),
publicBaseUrl: (
process.env.PUBLIC_BASE_URL ??
`http://localhost:${process.env.SERVER_PORT ?? '8080'}`
).replace(/\/$/, ''),
enrollSeed:
process.env.EVOFW_ENROLL_SEED ??
process.env.BUNDLE_SEED_HEX ??
'dev-enroll-seed-change-me',
}
}
+217
View File
@@ -0,0 +1,217 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import fp from 'fastify-plugin'
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { createHash } from 'node:crypto'
import { repos } from '@evofw/db'
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser
agentId?: string
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
}
user: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
}
}
}
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config' || path === '/api/v1/auth/config') return true
if (path.startsWith('/v1/agent/enroll')) return true
if (path.startsWith('/v1/agent/install')) return true
if (path.startsWith('/v1/agent/sync-script')) return true
if (path.startsWith('/v1/agent/mikrotik')) return true
return false
}
function isAgentPath(url: string): boolean {
const path = url.split('?')[0] ?? url
return (
path === '/v1/agent/policy' ||
path === '/v1/agent/apply-report' ||
path === '/v1/agent/heartbeat'
)
}
async function authPlugin(
app: FastifyInstance,
opts: { config: AppConfig },
) {
const { config } = opts
app.get('/api/auth/config', async () => ({
required: config.authRequired,
portal_url: config.authPortalUrl,
}))
app.get('/api/v1/auth/config', async () => ({
required: config.authRequired,
portal_url: config.authPortalUrl,
}))
if (config.authRequired) {
if (!config.jwtSecret || config.jwtSecret.length < 8) {
throw new Error(
'AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true',
)
}
await app.register(import('@fastify/jwt'), {
secret: config.jwtSecret,
verify: { allowedIss: [config.authIssuer] },
})
} else {
await app.register(import('@fastify/jwt'), {
secret: config.jwtSecret || 'dev-secret-change-me',
})
}
app.decorate(
'requireAuth',
async (request: FastifyRequest, reply: FastifyReply) => {
if (!config.authRequired) {
request.authUser = {
id: 'dev',
email: 'dev@local',
name: 'Dev',
apps: ['fw'],
permissions: [
'fw:dashboard:read',
'fw:agents:write',
'fw:lists:write',
'fw:policies:write',
'fw:stats:read',
'fw:settings:admin',
],
isAdmin: true,
}
return
}
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const payload = request.user
const apps = Array.isArray(payload.apps)
? payload.apps.map(String)
: []
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: []
if (!apps.includes('fw') && !payload.is_admin) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: 'Нет доступа к приложению EvoFirewall',
},
})
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps,
permissions: payload.is_admin
? [
'fw:dashboard:read',
'fw:agents:write',
'fw:lists:write',
'fw:policies:write',
'fw:stats:read',
'fw:settings:admin',
]
: permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, request.url)
if (
required &&
!request.authUser.isAdmin &&
!hasPermission(request.authUser.permissions, required)
) {
return reply.code(403).send({
error: {
code: 'FORBIDDEN',
message: `Недостаточно прав: ${required}`,
},
})
}
},
)
app.addHook('onRequest', async (request, reply) => {
if (isPublicPath(request.url)) return
if (isAgentPath(request.url)) {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Agent token required' },
})
}
const token = auth.slice('Bearer '.length).trim()
const agent = repos.getAgentByTokenHash(app.db, hashToken(token))
if (!agent || agent.status !== 'approved') {
return reply.code(agent?.status === 'pending' ? 403 : 401).send({
error: {
code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED',
message:
agent?.status === 'pending'
? 'Agent pending approval'
: 'Invalid agent token',
},
})
}
request.agentId = agent.id
return
}
})
}
declare module 'fastify' {
interface FastifyInstance {
requireAuth: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void | FastifyReply>
}
}
export default fp(authPlugin, { name: 'auth' })
export { hashToken }
+8
View File
@@ -0,0 +1,8 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
async function corsPlugin(app: FastifyInstance) {
await app.register(import('@fastify/cors'), { origin: true })
}
export default fp(corsPlugin, { name: 'cors' })
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import {
createDb,
createMemoryDb,
runMigrations,
type Db,
type Sqlite,
} from '@evofw/db'
import type { AppConfig } from '../config.js'
declare module 'fastify' {
interface FastifyInstance {
db: Db
sqlite: Sqlite
}
}
export interface DbPluginOptions {
config?: AppConfig
memory?: boolean
}
async function dbPlugin(app: FastifyInstance, opts: DbPluginOptions) {
const { db, sqlite } = opts.memory
? createMemoryDb()
: createDb(opts.config!.databaseUrl)
runMigrations(sqlite)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
app.addHook('onClose', async () => {
sqlite.close()
})
}
export default fp(dbPlugin, { name: 'db' })
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode = 400,
) {
super(message)
this.name = 'AppError'
}
}
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _req, reply) => {
if (err instanceof AppError) {
return reply.code(err.statusCode).send({
error: { code: err.code, message: err.message },
})
}
const e = err as { statusCode?: number; message?: string }
const status = e.statusCode ?? 500
const message =
status >= 500
? 'Внутренняя ошибка сервера'
: e.message || 'Ошибка запроса'
app.log.error(err)
return reply.code(status).send({
error: {
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
message,
},
})
})
}
export default fp(errorHandlerPlugin, { name: 'error-handler' })
+136
View File
@@ -0,0 +1,136 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import { AppError } from '../plugins/error-handler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const scriptsDir = join(__dirname, '../agent-scripts')
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/v1/agent/install.sh', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
})
app.get('/v1/agent/sync-script', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
})
app.get('/v1/agent/mikrotik-install.rsc', async (_req, reply) => {
const body = readFileSync(
join(scriptsDir, 'mikrotik-install.rsc'),
'utf-8',
)
return reply.type('text/plain').send(body)
})
app.post('/v1/agent/enroll', async (req, reply) => {
const seed = req.headers['x-evofw-seed']
const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seed || String(seed) !== expected) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
}
const body = enrollBodySchema.parse(req.body)
const id = crypto.randomUUID()
const tokenHash = hashToken(body.token)
const existing = repos.getAgentByTokenHash(app.db, tokenHash)
if (existing) {
throw new AppError('CONFLICT', 'Token already enrolled', 409)
}
const agent = repos.insertAgent(app.db, {
id,
name: body.name,
hostname: body.hostname ?? null,
platform: body.platform ?? 'linux',
tokenPrefix: body.token.slice(0, 12),
tokenHash,
status: 'pending',
policyMode: 'blacklist',
policyGeneration: 1,
clientVersion: body.client_version ?? null,
settingsJson: '{}',
createdAt: new Date().toISOString(),
})
return reply.code(201).send({
client_id: agent!.id,
id: agent!.id,
status: agent!.status,
name: agent!.name,
})
})
app.get('/v1/agent/policy', async (req) => {
const agentId = req.agentId!
const policy = evaluateAgentPolicy(app.db, agentId)
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return {
generation: policy.generation,
hash: policy.hash,
policy_mode: policy.policyMode,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
sync_interval_sec: policy.syncIntervalSec,
// compat aliases for simple clients
prefixes:
policy.policyMode === 'blacklist'
? policy.denyCidrs
: policy.allowCidrs,
total:
policy.policyMode === 'blacklist'
? policy.denyCidrs.length
: policy.allowCidrs.length,
}
})
app.post('/v1/agent/apply-report', async (req) => {
const agentId = req.agentId!
const body = applyReportBodySchema.parse(req.body)
const now = new Date().toISOString()
repos.updateAgent(app.db, agentId, {
lastApplyAt: now,
lastApplyStatus: body.status,
lastApplyError: body.error ?? null,
lastApplyPrefixCount: body.prefix_count ?? 0,
lastApplyPacketsDropped: body.packets_dropped ?? 0,
lastApplyPacketsAccepted: body.packets_accepted ?? 0,
lastApplyKernelMethod: body.kernel_method ?? null,
lastSeenAt: now,
lastSeenIp: req.ip,
})
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: body.packets_dropped ?? 0,
packetsAccepted: body.packets_accepted ?? 0,
prefixCount: body.prefix_count ?? 0,
kernelMethod: body.kernel_method ?? null,
recordedAt: now,
})
return { ok: true }
})
app.post('/v1/agent/heartbeat', async (req) => {
const agentId = req.agentId!
repos.updateAgent(app.db, agentId, {
lastSeenAt: new Date().toISOString(),
lastSeenIp: req.ip,
})
return { ok: true }
})
}
+413
View File
@@ -0,0 +1,413 @@
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createOverrideBodySchema,
createIpListBodySchema,
createPolicyRuleBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { refreshIpList } from '../services/lists/refresh.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import type { AppConfig } from '../config.js'
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
return {
id: a.id,
name: a.name,
hostname: a.hostname,
platform: a.platform,
token_prefix: a.tokenPrefix,
status: a.status,
policy_mode: a.policyMode,
policy_generation: a.policyGeneration,
last_seen_at: a.lastSeenAt,
last_seen_ip: a.lastSeenIp,
last_apply_at: a.lastApplyAt,
last_apply_status: a.lastApplyStatus,
last_apply_error: a.lastApplyError,
last_apply_prefix_count: a.lastApplyPrefixCount,
last_apply_packets_dropped: a.lastApplyPacketsDropped,
last_apply_packets_accepted: a.lastApplyPacketsAccepted,
last_apply_kernel_method: a.lastApplyKernelMethod,
client_version: a.clientVersion,
created_at: a.createdAt,
approved_at: a.approvedAt,
revoked_at: a.revokedAt,
}
}
export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get('/dashboard', async () => {
const all = repos.listAgents(app.db)
const now = Date.now()
const online = all.filter((a) => {
if (!a.lastSeenAt || a.status !== 'approved') return false
return now - Date.parse(a.lastSeenAt) < 5 * 60_000
})
return {
agents_total: all.length,
agents_approved: all.filter((a) => a.status === 'approved').length,
agents_online: online.length,
agents_pending: all.filter((a) => a.status === 'pending').length,
packets_dropped: all.reduce(
(s, a) => s + (a.lastApplyPacketsDropped ?? 0),
0,
),
packets_accepted: all.reduce(
(s, a) => s + (a.lastApplyPacketsAccepted ?? 0),
0,
),
lists_total: repos.listIpLists(app.db).length,
}
})
app.get('/install-context', async () => {
const seed =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
return {
suggested_cp_url: config.publicBaseUrl,
enroll_seed: seed,
install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`,
mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`,
sync_interval_sec: Number(
repos.getSetting(app.db, 'agent_sync_interval_sec') || '60',
),
}
})
// Agents
app.get('/agents', async () => ({
items: repos.listAgents(app.db).map(mapAgent),
}))
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return mapAgent(a)
})
app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const policy = evaluateAgentPolicy(app.db, a.id)
return {
...policy,
deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs,
policy_mode: policy.policyMode,
sync_interval_sec: policy.syncIntervalSec,
}
})
app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => {
const body = patchAgentBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
name: body.name,
policyMode: body.policy_mode,
settingsJson: body.settings
? JSON.stringify(body.settings)
: undefined,
policyGeneration:
body.policy_mode && body.policy_mode !== a.policyMode
? a.policyGeneration + 1
: a.policyGeneration,
})
return mapAgent(updated!)
})
app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: new Date().toISOString(),
})
return mapAgent(updated!)
})
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const updated = repos.updateAgent(app.db, a.id, {
status: 'revoked',
revokedAt: new Date().toISOString(),
})
return mapAgent(updated!)
})
app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => {
repos.deleteAgent(app.db, req.params.id)
return { ok: true }
})
app.post<{ Params: { id: string; sourceId: string } }>(
'/agents/:id/clone-from/:sourceId',
async (req) => {
const body = cloneFromBodySchema.parse(req.body ?? {})
const updated = repos.cloneRulesFrom(
app.db,
req.params.sourceId,
req.params.id,
body.include_overrides ?? false,
)
if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404)
return mapAgent(updated)
},
)
// Overrides
app.get<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => ({
items: repos.listOverrides(app.db, req.params.id).map((o) => ({
id: o.id,
agent_id: o.agentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
created_at: o.createdAt,
})),
}),
)
app.post<{ Params: { id: string } }>(
'/agents/:id/overrides',
async (req) => {
const body = createOverrideBodySchema.parse(req.body)
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const row = repos.insertOverride(app.db, {
id: crypto.randomUUID(),
agentId: a.id,
cidr: body.cidr,
action: body.action,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
})
repos.bumpAgentGeneration(app.db, a.id)
return {
id: row!.id,
agent_id: row!.agentId,
cidr: row!.cidr,
action: row!.action,
comment: row!.comment,
created_at: row!.createdAt,
}
},
)
app.delete<{ Params: { id: string; overrideId: string } }>(
'/agents/:id/overrides/:overrideId',
async (req) => {
repos.deleteOverride(app.db, req.params.overrideId)
repos.bumpAgentGeneration(app.db, req.params.id)
return { ok: true }
},
)
// Lists
app.get('/lists', async () => {
const items = repos.listIpLists(app.db).map((l) => ({
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
created_at: l.createdAt,
updated_at: l.updatedAt,
}))
return { items }
})
app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
const id = crypto.randomUUID()
const list = repos.insertIpList(app.db, {
id,
name: body.name,
type: body.type,
configJson: JSON.stringify(body.config ?? {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.entries?.length) {
repos.replaceIpListEntries(app.db, id, body.entries)
}
if (body.type !== 'static') {
await refreshIpList(app.db, id)
}
return {
id: list!.id,
name: list!.name,
type: list!.type,
config_json: list!.configJson,
created_at: list!.createdAt,
updated_at: list!.updatedAt,
}
})
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr),
created_at: l.createdAt,
updated_at: l.updatedAt,
}
})
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
await refreshIpList(app.db, req.params.id)
const l = repos.getIpList(app.db, req.params.id)
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
return {
id: l.id,
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
entry_count: repos.listIpListEntries(app.db, l.id).length,
}
})
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
repos.deleteIpList(app.db, req.params.id)
return { ok: true }
})
// Rules
app.get<{ Querystring: { agent_id?: string } }>('/rules', async (req) => {
const agentId =
req.query.agent_id === 'tenant' || req.query.agent_id === ''
? null
: req.query.agent_id
const items = (
agentId === undefined
? repos.listPolicyRules(app.db)
: repos.listPolicyRules(app.db, agentId)
).map((r) => ({
id: r.id,
agent_id: r.agentId,
priority: r.priority,
action: r.action,
list_id: r.listId,
cidr: r.cidr,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}))
return { items }
})
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
if (!body.list_id && !body.cidr) {
throw new AppError('VALIDATION_ERROR', 'list_id or cidr required')
}
const row = repos.insertPolicyRule(app.db, {
id: crypto.randomUUID(),
agentId: body.agent_id ?? null,
priority: body.priority,
action: body.action,
listId: body.list_id ?? null,
cidr: body.cidr ?? null,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (body.agent_id) repos.bumpAgentGeneration(app.db, body.agent_id)
else {
for (const a of repos.listAgents(app.db)) {
if (a.status === 'approved') repos.bumpAgentGeneration(app.db, a.id)
}
}
return {
id: row!.id,
agent_id: row!.agentId,
priority: row!.priority,
action: row!.action,
list_id: row!.listId,
cidr: row!.cidr,
comment: row!.comment,
created_at: row!.createdAt,
updated_at: row!.updatedAt,
}
})
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
const rule = repos.getPolicyRule(app.db, req.params.id)
repos.deletePolicyRule(app.db, req.params.id)
if (rule?.agentId) repos.bumpAgentGeneration(app.db, rule.agentId)
return { ok: true }
})
// Stats
app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({
items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
app.get('/stats/recent', async () => ({
items: repos.listRecentStats(app.db).map((s) => ({
id: s.id,
agent_id: s.agentId,
packets_dropped: s.packetsDropped,
packets_accepted: s.packetsAccepted,
prefix_count: s.prefixCount,
kernel_method: s.kernelMethod,
recorded_at: s.recordedAt,
})),
}))
// Settings
app.get('/settings', async () => {
const rows = repos.listSettings(app.db)
const map: Record<string, string> = {}
for (const r of rows) {
if (r.key === 'evobgp_api_token' && r.value) {
map[r.key] = '********'
} else {
map[r.key] = r.value
}
}
if (!map.enroll_seed) map.enroll_seed = config.enrollSeed
return map
})
app.put('/settings', async (req) => {
const body = req.body as Record<string, string>
for (const [k, v] of Object.entries(body)) {
if (typeof v !== 'string') continue
if (k === 'evobgp_api_token' && v === '********') continue
repos.setSetting(app.db, k, v)
}
return { ok: true }
})
}
+14
View File
@@ -0,0 +1,14 @@
import type { FastifyPluginAsync } from 'fastify'
import { healthCheck } from '@evofw/db'
export const healthRoutes: FastifyPluginAsync = async (app) => {
app.get('/health', async () => ({ status: 'ok', service: 'evofirewall' }))
app.get('/ready', async (_req, reply) => {
try {
healthCheck(app.sqlite)
return { status: 'ready' }
} catch {
return reply.code(503).send({ status: 'not_ready' })
}
})
}
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
const app = await buildApp({ config })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`listening on ${config.serverPort}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+174
View File
@@ -0,0 +1,174 @@
import { createHash } from 'node:crypto'
import { resolve4, resolve6 } from 'node:dns/promises'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
function uniq(cidrs: string[]): string[] {
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
}
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
}
async function fetchJsonUrl(url: string): Promise<string[]> {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
})
if (!res.ok) throw new Error(`JSON URL HTTP ${res.status}`)
const data = (await res.json()) as unknown
const out: string[] = []
const push = (v: unknown) => {
if (typeof v === 'string' && v.trim()) out.push(v.trim())
}
if (Array.isArray(data)) {
for (const item of data) {
if (typeof item === 'string') push(item)
else if (item && typeof item === 'object') {
const o = item as Record<string, unknown>
push(o.cidr ?? o.prefix ?? o.ip ?? o.network)
}
}
} else if (data && typeof data === 'object') {
const o = data as Record<string, unknown>
const arr = (o.prefixes ?? o.cidrs ?? o.ips ?? o.items) as unknown
if (Array.isArray(arr)) {
for (const item of arr) {
if (typeof item === 'string') push(item)
else if (item && typeof item === 'object') {
const x = item as Record<string, unknown>
push(x.cidr ?? x.prefix ?? x.ip)
}
}
}
}
return uniq(out)
}
async function resolveDomains(domains: string[]): Promise<string[]> {
const out: string[] = []
for (const d of domains) {
const host = d.trim().replace(/\.$/, '')
if (!host) continue
try {
const a = await resolve4(host)
out.push(...a.map((ip) => `${ip}/32`))
} catch {
/* ignore */
}
try {
const aaaa = await resolve6(host)
out.push(...aaaa.map((ip) => `${ip}/128`))
} catch {
/* ignore */
}
}
return uniq(out)
}
async function fetchEvobgpCommunity(
apiUrl: string,
token: string,
communityId: string,
): Promise<string[]> {
const base = apiUrl.replace(/\/$/, '')
// Prefer published revision prefixes filtered by community when available.
const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes`
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(45_000),
})
if (res.ok) {
const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] }
if (Array.isArray(data.prefixes)) return uniq(data.prefixes)
if (Array.isArray(data.items)) {
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
}
}
// Fallback: modules lookup / openapi-compatible list
const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}`
const res2 = await fetch(alt, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(45_000),
})
if (!res2.ok) {
throw new Error(`EvoBGP community fetch failed: ${res.status}/${res2.status}`)
}
const data2 = (await res2.json()) as { prefixes?: string[] }
return uniq(data2.prefixes ?? [])
}
export async function refreshIpList(db: Db, listId: string): Promise<void> {
const list = repos.getIpList(db, listId)
if (!list) return
let config: Record<string, unknown> = {}
try {
config = JSON.parse(list.configJson || '{}') as Record<string, unknown>
} catch {
config = {}
}
try {
let cidrs: string[] = []
if (list.type === 'static') {
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr)
} else if (list.type === 'json_url') {
const url = String(config.url ?? '')
if (!url) throw new Error('config.url required')
cidrs = await fetchJsonUrl(url)
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'domains') {
const domains = Array.isArray(config.domains)
? (config.domains as string[])
: String(config.domains ?? '')
.split(/[\s,]+/)
.filter(Boolean)
cidrs = await resolveDomains(domains)
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') {
const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
const token =
String(config.api_token ?? '') ||
repos.getSetting(db, 'evobgp_api_token')
const communityId = String(config.community_id ?? '')
if (!apiUrl || !token || !communityId) {
throw new Error('evobgp_api_url, token and community_id required')
}
cidrs = await fetchEvobgpCommunity(apiUrl, token, communityId)
repos.replaceIpListEntries(db, listId, cidrs)
}
const contentHash = hashCidrs(cidrs)
repos.updateIpList(db, listId, {
contentHash,
refreshedAt: new Date().toISOString(),
lastError: null,
})
// Bump all agents so they re-fetch policy
for (const a of repos.listAgents(db)) {
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
}
} catch (err) {
repos.updateIpList(db, listId, {
lastError: err instanceof Error ? err.message : String(err),
refreshedAt: new Date().toISOString(),
})
}
}
export async function refreshAllLists(db: Db): Promise<void> {
for (const list of repos.listIpLists(db)) {
if (list.type === 'static') continue
await refreshIpList(db, list.id)
}
}
+85
View File
@@ -0,0 +1,85 @@
import { createHash } from 'node:crypto'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
export type EvaluatedPolicy = {
generation: number
hash: string
policyMode: 'blacklist' | 'whitelist'
denyCidrs: string[]
allowCidrs: string[]
syncIntervalSec: number
}
function uniq(cidrs: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const c of cidrs) {
const t = c.trim()
if (!t || seen.has(t)) continue
seen.add(t)
out.push(t)
}
return out.sort()
}
function expandList(db: Db, listId: string | null | undefined): string[] {
if (!listId) return []
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
}
/** Evaluate allow/deny sets for an agent. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const agent = repos.getAgent(db, agentId)
if (!agent) {
throw new Error(`agent not found: ${agentId}`)
}
const agentRules = repos.listPolicyRules(db, agentId)
const tenantRules = repos.listPolicyRules(db, null)
const ordered = [...agentRules, ...tenantRules].sort(
(a, b) => a.priority - b.priority,
)
const deny: string[] = []
const allow: string[] = []
for (const rule of ordered) {
const cidrs = rule.cidr
? [rule.cidr]
: expandList(db, rule.listId)
if (rule.action === 'deny') deny.push(...cidrs)
else allow.push(...cidrs)
}
for (const o of repos.listOverrides(db, agentId)) {
if (o.action === 'deny') deny.push(o.cidr)
else allow.push(o.cidr)
}
const denyCidrs = uniq(deny)
const allowCidrs = uniq(allow)
const policyMode = (agent.policyMode === 'whitelist'
? 'whitelist'
: 'blacklist') as 'blacklist' | 'whitelist'
const payload = JSON.stringify({
generation: agent.policyGeneration,
policyMode,
denyCidrs,
allowCidrs,
})
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
const syncIntervalSec =
Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60
return {
generation: agent.policyGeneration,
hash,
policyMode,
denyCidrs,
allowCidrs,
syncIntervalSec,
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EvoFirewall</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35 -5
View File
@@ -1,12 +1,42 @@
{
"name": "@evofw/web",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "echo \"web: scaffold — Vite app not initialized yet\"",
"build": "echo \"web: scaffold — skip\"",
"lint": "echo \"web: scaffold — skip\"",
"test": "echo \"web: scaffold — skip\""
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@evofw/shared": "workspace:*",
"@evofw/ui": "workspace:*",
"@hookform/resolvers": "^5.0.0",
"@tailwindcss/vite": "^4.1.0",
"@tanstack/react-query": "^5.80.0",
"@tanstack/react-router": "^1.120.0",
"@tanstack/react-table": "^8.21.0",
"@tanstack/router-plugin": "^1.120.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.0",
"recharts": "^2.15.0",
"sonner": "^1.7.0",
"tailwindcss": "^4.1.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"typescript": "^5.8.3",
"vite": "^6.3.0",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,112 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboard,
Server,
List,
Shield,
BarChart3,
Settings,
LogOut,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evofw/ui/components/sidebar'
import { Button } from '@evofw/ui/components/button'
import { Separator } from '@evofw/ui/components/separator'
import { logout, CURRENT_APP_ID } from '@/lib/auth'
import type { ReactNode } from 'react'
const NAV = [
{ to: '/', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/agents', label: 'Агенты', icon: Server },
{ to: '/lists', label: 'Списки IP', icon: List },
{ to: '/rules', label: 'Правила', icon: Shield },
{ to: '/stats', label: 'Статистика', icon: BarChart3 },
{ to: '/settings', label: 'Настройки', icon: Settings },
] as const
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as React.CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader className="border-b px-3 py-3">
<div className="flex items-center gap-2 px-1">
<Shield className="size-5" />
<div className="flex flex-col">
<span className="text-sm font-semibold">EvoFirewall</span>
<span className="text-muted-foreground text-[10px] uppercase">
{CURRENT_APP_ID}
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{NAV.map((item) => {
const Icon = item.icon
const active =
item.to === '/'
? pathname === '/'
: pathname.startsWith(item.to)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
isActive={active}
render={<Link to={item.to} />}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => logout()}
>
<LogOut className="size-4" />
Выйти
</Button>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="bg-background sticky top-0 z-10 flex h-12 items-center gap-2 border-b px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="h-4" />
<span className="text-muted-foreground text-sm">Control plane</span>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
</SidebarProvider>
)
}
+104
View File
@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame } from '@/components/reui/frame'
import { cn } from '@evofw/ui/lib/utils'
export type KpiItem = {
id: string
label: string
value: string | number
hint?: string
to?: string
}
/** KPI grid — preview: https://reui.io/preview/base/stats-12 */
export function KpiStatGrid({
items,
className,
}: {
items: KpiItem[]
className?: string
}) {
return (
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item) => {
const inner = (
<Frame dense className="h-full transition-colors hover:bg-muted/40">
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{item.label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{item.value}</div>
{item.hint ? (
<div className="text-muted-foreground mt-1 text-xs">{item.hint}</div>
) : null}
</Frame>
)
return item.to ? (
<Link key={item.id} to={item.to} className="block no-underline">
{inner}
</Link>
) : (
<div key={item.id}>{inner}</div>
)
})}
</div>
)
}
export function PageShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
)
}
export function PageHeader({
title,
description,
actions,
}: {
title: string
description?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</div>
)
}
export function EmptyState({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<Frame className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div className="font-medium">{title}</div>
{description ? (
<p className="text-muted-foreground max-w-md text-sm">{description}</p>
) : null}
{action}
</Frame>
)
}
+59
View File
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react'
import { cn } from '@evofw/ui/lib/utils'
/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */
export function Frame({
children,
className,
dense,
}: {
children: ReactNode
className?: string
dense?: boolean
}) {
return (
<div
className={cn(
'bg-card text-card-foreground rounded-xl border shadow-xs',
dense ? 'p-3' : 'p-4 md:p-5',
className,
)}
>
{children}
</div>
)
}
export function FrameHeader({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('mb-3 flex flex-wrap items-start justify-between gap-2', className)}>
{children}
</div>
)
}
export function FrameTitle({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return <h2 className={cn('text-base font-semibold tracking-tight', className)}>{children}</h2>
}
export function FrameDescription({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return <p className={cn('text-muted-foreground text-sm', className)}>{children}</p>
}
+34
View File
@@ -0,0 +1,34 @@
import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './auth'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export async function apiFetch<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
if (!headers.has('Content-Type') && init.body) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(`${API_BASE}${path}`, { ...init, headers })
if (res.status === 401 && isAuthEnabled()) {
clearToken()
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('Unauthorized')
}
if (!res.ok) {
let message = res.statusText
try {
const body = (await res.json()) as { error?: { message?: string } }
message = body.error?.message ?? message
} catch {
/* ignore */
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
+126
View File
@@ -0,0 +1,126 @@
/** Portal JWT storage for EvoFirewall (app id `fw`). */
const TOKEN_KEY = 'fw_auth_token'
const HANDOFF_AT_KEY = 'fw_portal_handoff_at'
const HANDOFF_COOLDOWN_MS = 12_000
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
try {
const res = await fetch(`${API_BASE}/api/auth/config`)
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* fallback */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
return runtimeConfig?.required ?? viteAuthEnabled()
}
export function authPortalUrl(): string {
return runtimeConfig?.portalUrl ?? vitePortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
return Date.now() - Number(raw) < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function parseClaims(token: string): AccessClaims | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json) as AccessClaims
} catch {
return null
}
}
export function isTokenValid(): boolean {
const t = getToken()
if (!t) return false
const c = parseClaims(t)
if (!c?.exp) return !!t
return c.exp * 1000 > Date.now() + 5_000
}
export function redirectToPortalLogin(returnTo: string) {
if (isPortalHandoffCoolingDown()) return
markPortalHandoff()
const portal = authPortalUrl()
const url = `${portal}/?return_to=${encodeURIComponent(returnTo)}`
window.location.href = url
}
export function logout() {
clearToken()
window.location.href = `${authPortalUrl()}/logout`
}
export const CURRENT_APP_ID = 'fw'
+39
View File
@@ -0,0 +1,39 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { ThemeProvider } from 'next-themes'
import { Toaster } from '@evofw/ui/components/sonner'
import { TooltipProvider } from '@evofw/ui/components/tooltip'
import { routeTree } from './routeTree.gen'
import '@evofw/ui/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10_000, retry: 1 },
},
})
const router = createRouter({
routeTree,
context: { queryClient },
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<RouterProvider router={router} />
<Toaster />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
+82
View File
@@ -0,0 +1,82 @@
import { queryOptions } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared'
export const dashboardQueryOptions = () =>
queryOptions({
queryKey: ['dashboard'],
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
})
export const agentsQueryOptions = () =>
queryOptions({
queryKey: ['agents'],
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
})
export const agentQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agents', id],
queryFn: () => apiFetch<Agent>(`/api/v1/agents/${id}`),
})
export const listsQueryOptions = () =>
queryOptions({
queryKey: ['lists'],
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
})
export const rulesQueryOptions = (agentId?: string) =>
queryOptions({
queryKey: ['rules', agentId ?? 'all'],
queryFn: () =>
apiFetch<{ items: PolicyRule[] }>(
`/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`,
),
})
export const installContextQueryOptions = () =>
queryOptions({
queryKey: ['install-context'],
queryFn: () =>
apiFetch<{
suggested_cp_url: string
enroll_seed: string
install_sh_url: string
mikrotik_url: string
sync_interval_sec: number
}>('/api/v1/install-context'),
})
export const settingsQueryOptions = () =>
queryOptions({
queryKey: ['settings'],
queryFn: () => apiFetch<Record<string, string>>('/api/v1/settings'),
})
export const agentStatsQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agent-stats', id],
queryFn: () =>
apiFetch<{
items: {
packets_dropped: number
packets_accepted: number
recorded_at: string
}[]
}>(`/api/v1/agents/${id}/stats`),
})
export const recentStatsQueryOptions = () =>
queryOptions({
queryKey: ['stats-recent'],
queryFn: () =>
apiFetch<{
items: {
agent_id: string
packets_dropped: number
packets_accepted: number
recorded_at: string
}[]
}>('/api/v1/stats/recent'),
})
+244
View File
@@ -0,0 +1,244 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthAgentsRouteImport } from './routes/_auth/agents'
import { Route as AuthListsRouteImport } from './routes/_auth/lists'
import { Route as AuthRulesRouteImport } from './routes/_auth/rules'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthStatsRouteImport } from './routes/_auth/stats'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAgentsRoute = AuthAgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => AuthRoute,
} as any)
const AuthListsRoute = AuthListsRouteImport.update({
id: '/lists',
path: '/lists',
getParentRoute: () => AuthRoute,
} as any)
const AuthRulesRoute = AuthRulesRouteImport.update({
id: '/rules',
path: '/rules',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthStatsRoute = AuthStatsRouteImport.update({
id: '/stats',
path: '/stats',
getParentRoute: () => AuthRoute,
} as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AuthAgentsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRoutesByTo {
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteWithChildren
'/_auth/agents': typeof AuthAgentsRouteWithChildren
'/_auth/lists': typeof AuthListsRoute
'/_auth/rules': typeof AuthRulesRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/agents/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/'
| '/agents/$id'
id:
| '__root__'
| '/_auth'
| '/_auth/agents'
| '/_auth/lists'
| '/_auth/rules'
| '/_auth/settings'
| '/_auth/stats'
| '/auth/callback'
| '/_auth/'
| '/_auth/agents/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthRoute: typeof AuthRouteWithChildren
AuthCallbackRoute: typeof AuthCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/': {
id: '/_auth/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/agents': {
id: '/_auth/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AuthAgentsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/lists': {
id: '/_auth/lists'
path: '/lists'
fullPath: '/lists'
preLoaderRoute: typeof AuthListsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rules': {
id: '/_auth/rules'
path: '/rules'
fullPath: '/rules'
preLoaderRoute: typeof AuthRulesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/stats': {
id: '/_auth/stats'
path: '/stats'
fullPath: '/stats'
preLoaderRoute: typeof AuthStatsRouteImport
parentRoute: typeof AuthRoute
}
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/agents/$id': {
id: '/_auth/agents/$id'
path: '/$id'
fullPath: '/agents/$id'
preLoaderRoute: typeof AuthAgentsIdRouteImport
parentRoute: typeof AuthAgentsRoute
}
}
}
interface AuthAgentsRouteChildren {
AuthAgentsIdRoute: typeof AuthAgentsIdRoute
}
const AuthAgentsRouteChildren: AuthAgentsRouteChildren = {
AuthAgentsIdRoute: AuthAgentsIdRoute,
}
const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren(
AuthAgentsRouteChildren,
)
interface AuthRouteChildren {
AuthAgentsRoute: typeof AuthAgentsRouteWithChildren
AuthListsRoute: typeof AuthListsRoute
AuthRulesRoute: typeof AuthRulesRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthStatsRoute: typeof AuthStatsRoute
AuthIndexRoute: typeof AuthIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentsRoute: AuthAgentsRouteWithChildren,
AuthListsRoute: AuthListsRoute,
AuthRulesRoute: AuthRulesRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthStatsRoute: AuthStatsRoute,
AuthIndexRoute: AuthIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
AuthRoute: AuthRouteWithChildren,
AuthCallbackRoute: AuthCallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
View File
+25
View File
@@ -0,0 +1,25 @@
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import {
ensureAuthConfig,
isAuthEnabled,
isTokenValid,
redirectToPortalLogin,
} from '@/lib/auth'
export type RouterContext = {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ location }) => {
await ensureAuthConfig()
if (!isAuthEnabled()) return
if (location.pathname.startsWith('/auth/')) return
if (!isTokenValid()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('redirecting to portal')
}
},
component: () => <Outlet />,
})
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
export const Route = createFileRoute('/_auth')({
component: () => (
<AppShell>
<Outlet />
</AppShell>
),
})
+236
View File
@@ -0,0 +1,236 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import {
agentQueryOptions,
agentsQueryOptions,
rulesQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/agents/$id')({
component: AgentDetailPage,
})
function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const agentQ = useQuery(agentQueryOptions(id))
const rulesQ = useQuery(rulesQueryOptions(id))
const agentsQ = useQuery(agentsQueryOptions())
const [cidr, setCidr] = useState('')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [cloneFrom, setCloneFrom] = useState('')
const patchMode = useMutation({
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
apiFetch(`/api/v1/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ policy_mode }),
}),
onSuccess: () => {
toast.success('Режим обновлён')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const addOverride = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/overrides`, {
method: 'POST',
body: JSON.stringify({ cidr, action }),
}),
onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync')
setCidr('')
void qc.invalidateQueries({ queryKey: ['agents', id] })
},
onError: (e: Error) => toast.error(e.message),
})
const clone = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
method: 'POST',
body: JSON.stringify({ include_overrides: true }),
}),
onSuccess: () => {
toast.success('Правила скопированы')
void qc.invalidateQueries({ queryKey: ['rules'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (!a) {
return <PageShell><PageHeader title="Агент" description="Загрузка…" /></PageShell>
}
return (
<PageShell>
<PageHeader
title={a.name}
description={`${a.platform} · ${a.status} · gen ${a.policy_generation}`}
actions={
<Link to="/agents" className="inline-flex">
<Button variant="outline" type="button">
К списку
</Button>
</Link>
}
/>
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Политика</FrameTitle>
<FrameDescription>
blacklist = deny set; whitelist = allow set + default drop
</FrameDescription>
</FrameHeader>
<div className="flex flex-wrap gap-2">
<Button
variant={a.policy_mode === 'blacklist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('blacklist')}
>
Blacklist
</Button>
<Button
variant={a.policy_mode === 'whitelist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('whitelist')}
>
Whitelist
</Button>
</div>
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Dropped</dt>
<dd className="tabular-nums">{a.last_apply_packets_dropped ?? 0}</dd>
<dt className="text-muted-foreground">Accepted</dt>
<dd className="tabular-nums">{a.last_apply_packets_accepted ?? 0}</dd>
<dt className="text-muted-foreground">Kernel</dt>
<dd>{a.last_apply_kernel_method ?? '—'}</dd>
<dt className="text-muted-foreground">Last apply</dt>
<dd className="text-xs">{a.last_apply_at ?? '—'}</dd>
</dl>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Мгновенный IP override</FrameTitle>
<FrameDescription>
Обновится на агенте на следующей итерации sync (~1 мин)
</FrameDescription>
</FrameHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label>CIDR / IP</Label>
<Input
placeholder="1.2.3.4/32"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Действие</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => addOverride.mutate()}
disabled={!cidr || addOverride.isPending}
>
Добавить override
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Копировать правила</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-3">
<Select value={cloneFrom} onValueChange={setCloneFrom}>
<SelectTrigger>
<SelectValue placeholder="Источник" />
</SelectTrigger>
<SelectContent>
{(agentsQ.data?.items ?? [])
.filter((x) => x.id !== id)
.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать (с overrides)
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Правила агента</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
</PageShell>
)
}
+231
View File
@@ -0,0 +1,231 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy, Check } from 'lucide-react'
import { useMemo, useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import {
agentsQueryOptions,
installContextQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Badge } from '@evofw/ui/components/badge'
export const Route = createFileRoute('/_auth/agents')({
component: AgentsPage,
})
function AgentsPage() {
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const installQ = useQuery(installContextQueryOptions())
const [name, setName] = useState('web-01')
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const revoke = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const installCmd = useMemo(() => {
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
const seed = installQ.data?.enroll_seed ?? '<seed>'
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
}, [installQ.data, name])
const items = agentsQ.data?.items ?? []
const pending = items.filter((a) => a.status === 'pending')
return (
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — enroll, approve, policy mode. Preview: data-grid-filtering-2"
/>
<Frame>
<FrameHeader>
<div>
<FrameTitle>Установка Linux</FrameTitle>
<FrameDescription>
One-liner. После enroll одобрите агента ниже.
</FrameDescription>
</div>
</FrameHeader>
<div className="mb-3 flex max-w-sm flex-col gap-2">
<Label htmlFor="cname">Имя клиента</Label>
<Input
id="cname"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
{installCmd}
</pre>
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(installCmd.replace(/\\\n\s*/g, ' '))
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
{installQ.data?.mikrotik_url ? (
<p className="text-muted-foreground mt-3 text-sm">
MikroTik:{' '}
<a className="underline" href={installQ.data.mikrotik_url}>
mikrotik-install.rsc
</a>
</p>
) : null}
</Frame>
{pending.length > 0 ? (
<Frame>
<FrameHeader>
<FrameTitle>Запросы ({pending.length})</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-2">
{pending.map((a) => (
<div
key={a.id}
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<div className="font-medium">{a.name}</div>
<div className="text-muted-foreground text-xs">
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
disabled={approve.isPending}
>
<Check data-icon="inline-start" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
</Frame>
) : null}
<Frame>
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState
title="Нет агентов"
description="Установите agent на сервер и одобрите запрос."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Платформа</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead>Seen</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((a) => (
<TableRow key={a.id}>
<TableCell>
<Link
to="/agents/$id"
params={{ id: a.id }}
className="font-medium underline-offset-4 hover:underline"
>
{a.name}
</Link>
</TableCell>
<TableCell>{a.platform}</TableCell>
<TableCell>
<Badge variant="secondary">{a.status}</Badge>
</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{a.last_seen_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
</PageShell>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, agentsQueryOptions, recentStatsQueryOptions } from '@/queries'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/')({
component: DashboardPage,
})
function DashboardPage() {
const dash = useQuery(dashboardQueryOptions())
const agents = useQuery(agentsQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const d = dash.data
const items = [
{
id: 'agents',
label: 'Агенты',
value: d?.agents_approved ?? '—',
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
to: '/agents',
},
{
id: 'dropped',
label: 'Dropped',
value: d?.packets_dropped ?? '—',
hint: 'сумма counters',
to: '/stats',
},
{
id: 'accepted',
label: 'Accepted',
value: d?.packets_accepted ?? '—',
hint: 'сумма counters',
to: '/stats',
},
{
id: 'lists',
label: 'Списки IP',
value: d?.lists_total ?? '—',
to: '/lists',
},
]
return (
<PageShell>
<PageHeader
title="Дашборд"
description="Обзор агентов и пакетной статистики — ReUI stats-12 / dashboard-1"
/>
{dash.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : (
<KpiStatGrid items={items} />
)}
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead className="text-right">Dropped</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(agents.data?.items ?? []).slice(0, 8).map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.status}</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-right tabular-nums">
{a.last_apply_packets_dropped ?? 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 10).map((s, i) => (
<TableRow key={`${s.agent_id}-${s.recorded_at}-${i}`}>
<TableCell className="text-muted-foreground text-xs">
{s.recorded_at}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
</PageShell>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/lists')({
component: ListsPage,
})
function ListsPage() {
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const [name, setName] = useState('')
const [type, setType] = useState<
'static' | 'json_url' | 'domains' | 'evobgp_community'
>('static')
const [extra, setExtra] = useState('')
const create = useMutation({
mutationFn: async () => {
const config: Record<string, unknown> = {}
let entries: string[] | undefined
if (type === 'static') {
entries = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else if (type === 'json_url') {
config.url = extra.trim()
} else if (type === 'domains') {
config.domains = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else {
config.community_id = extra.trim()
}
return apiFetch('/api/v1/lists', {
method: 'POST',
body: JSON.stringify({ name, type, config, entries }),
})
},
onSuccess: () => {
toast.success('Список создан')
setName('')
setExtra('')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const refresh = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
onSuccess: () => {
toast.success('Обновлено')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['lists'] })
},
})
const items = listsQ.data?.items ?? []
return (
<PageShell>
<PageHeader
title="Списки IP"
description="static · JSON URL · domains · EvoBGP community"
/>
<Frame>
<FrameHeader>
<FrameTitle>Новый список</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3">
<div className="flex flex-col gap-2">
<Label>Имя</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label>Тип</Label>
<Select
value={type}
onValueChange={(v) =>
setType(v as typeof type)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="json_url">json_url</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="evobgp_community">evobgp_community</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>
{type === 'static'
? 'CIDR (через пробел/запятую)'
: type === 'json_url'
? 'URL JSON'
: type === 'domains'
? 'Домены'
: 'Community ID'}
</Label>
<Textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={3}
/>
</div>
<Button
disabled={!name || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Списки</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState title="Пусто" description="Создайте первый список." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Entries</TableHead>
<TableHead>Refresh</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell>{l.type}</TableCell>
<TableCell className="tabular-nums">
{l.entry_count ?? 0}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{l.last_error ?? l.refreshed_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{l.type !== 'static' ? (
<Button
size="sm"
variant="outline"
onClick={() => refresh.mutate(l.id)}
>
Refresh
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(l.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
</PageShell>
)
}
+182
View File
@@ -0,0 +1,182 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { rulesQueryOptions, listsQueryOptions, agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/rules')({
component: RulesPage,
})
function RulesPage() {
const qc = useQueryClient()
const rulesQ = useQuery(rulesQueryOptions())
const listsQ = useQuery(listsQueryOptions())
const agentsQ = useQuery(agentsQueryOptions())
const [priority, setPriority] = useState('100')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [listId, setListId] = useState('')
const [agentId, setAgentId] = useState('tenant')
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/rules', {
method: 'POST',
body: JSON.stringify({
priority: Number(priority),
action,
list_id: listId || null,
agent_id: agentId === 'tenant' ? null : agentId,
}),
}),
onSuccess: () => {
toast.success('Правило создано')
void qc.invalidateQueries({ queryKey: ['rules'] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
})
return (
<PageShell>
<PageHeader
title="Правила"
description="Упорядоченные allow/deny по списку или CIDR (tenant + per-agent)"
/>
<Frame>
<FrameHeader>
<FrameTitle>Новое правило</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Priority</Label>
<Input
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Action</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Список</Label>
<Select value={listId} onValueChange={setListId}>
<SelectTrigger>
<SelectValue placeholder="IP list" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Scope</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant">Tenant default</SelectItem>
{(agentsQ.data?.items ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="sm:col-span-2"
disabled={!listId || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Все правила</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Agent</TableHead>
<TableHead>List / CIDR</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="text-xs">
{r.agent_id ?? 'tenant'}
</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(r.id)}
>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</PageShell>
)
}
+100
View File
@@ -0,0 +1,100 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage,
})
function SettingsPage() {
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const [form, setForm] = useState<Record<string, string>>({})
useEffect(() => {
if (settingsQ.data) setForm(settingsQ.data)
}, [settingsQ.data])
const save = useMutation({
mutationFn: () =>
apiFetch('/api/v1/settings', {
method: 'PUT',
body: JSON.stringify(form),
}),
onSuccess: () => {
toast.success('Сохранено')
void qc.invalidateQueries({ queryKey: ['settings'] })
void qc.invalidateQueries({ queryKey: ['install-context'] })
},
onError: (e: Error) => toast.error(e.message),
})
const fields = [
{
key: 'enroll_seed',
label: 'Enroll seed',
hint: 'X-EvoFW-Seed для install.sh',
},
{
key: 'evobgp_api_url',
label: 'EvoBGP API URL',
hint: 'Источник community prefixes',
},
{
key: 'evobgp_api_token',
label: 'EvoBGP API token',
hint: 'Bearer для интеграции',
},
{
key: 'agent_sync_interval_sec',
label: 'Agent sync interval (sec)',
hint: 'Рекомендуется 30–60',
},
]
return (
<PageShell>
<PageHeader
title="Настройки"
description="Интеграции и enroll — settings-16"
/>
<Frame>
<FrameHeader>
<div>
<FrameTitle>Control plane</FrameTitle>
<FrameDescription>
Auth-portal app id: fw · JWT через AUTH_*
</FrameDescription>
</div>
</FrameHeader>
<div className="flex max-w-xl flex-col gap-4">
{fields.map((f) => (
<div key={f.key} className="flex flex-col gap-2">
<Label htmlFor={f.key}>{f.label}</Label>
<Input
id={f.key}
type={f.key.includes('token') ? 'password' : 'text'}
value={form[f.key] ?? ''}
onChange={(e) =>
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
}
/>
<p className="text-muted-foreground text-xs">{f.hint}</p>
</div>
))}
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</Button>
</div>
</Frame>
</PageShell>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evofw/ui/components/chart'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
export const Route = createFileRoute('/_auth/stats')({
component: StatsPage,
})
const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
} satisfies ChartConfig
function StatsPage() {
const dash = useQuery(dashboardQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const series = [...(stats.data?.items ?? [])]
.reverse()
.slice(-40)
.map((s) => ({
t: s.recorded_at.slice(11, 19),
dropped: s.packets_dropped,
accepted: s.packets_accepted,
}))
return (
<PageShell>
<PageHeader
title="Статистика"
description="История apply-report counters — dashboard-1 / charts"
/>
<KpiStatGrid
items={[
{
id: 'd',
label: 'Dropped (sum)',
value: dash.data?.packets_dropped ?? 0,
},
{
id: 'a',
label: 'Accepted (sum)',
value: dash.data?.packets_accepted ?? 0,
},
{
id: 'o',
label: 'Online agents',
value: dash.data?.agents_online ?? 0,
},
]}
/>
<Frame>
<FrameHeader>
<FrameTitle>Тренд (последние samples)</FrameTitle>
</FrameHeader>
{series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет данных дождитесь apply-report от агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
<AreaChart data={series}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="dropped"
type="monotone"
fill="var(--color-dropped)"
stroke="var(--color-dropped)"
fillOpacity={0.3}
/>
<Area
dataKey="accepted"
type="monotone"
fill="var(--color-accepted)"
stroke="var(--color-accepted)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Сырые samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Time</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 50).map((s, i) => (
<TableRow key={`${s.agent_id}-${i}`}>
<TableCell className="font-mono text-xs">{s.agent_id.slice(0, 8)}</TableCell>
<TableCell className="text-xs">{s.recorded_at}</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</PageShell>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { setToken } from '@/lib/auth'
export const Route = createFileRoute('/auth/callback')({
component: AuthCallback,
})
function AuthCallback() {
const hash = typeof window !== 'undefined' ? window.location.hash : ''
const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('access_token')
if (token) {
setToken(token)
window.location.replace('/')
} else {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">
Нет access_token в URL. Войдите через auth-portal.
</p>
</div>
)
}
return null
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@evofw/ui/*": ["../../packages/ui/src/*"],
"@evofw/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"]
}
+41
View File
@@ -0,0 +1,41 @@
import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: './src/routes', target: 'react' }),
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@evofw/shared': path.resolve(
__dirname,
'../../packages/shared/src/index.ts',
),
'@evofw/ui/components': path.resolve(
__dirname,
'../../packages/ui/src/components',
),
'@evofw/ui/hooks': path.resolve(
__dirname,
'../../packages/ui/src/hooks',
),
'@evofw/ui/lib': path.resolve(__dirname, '../../packages/ui/src/lib'),
},
},
server: {
port: 5177,
fs: { allow: [path.resolve(__dirname, '../..')] },
proxy: {
'/api': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/ready': 'http://localhost:8080',
'/v1': 'http://localhost:8080',
},
},
})
@@ -0,0 +1,21 @@
services:
evofirewall:
image: git.shts.su/denozord/EvoFirewall:latest
restart: unless-stopped
environment:
DATABASE_URL: sqlite:/data/app.db
SERVER_PORT: "8080"
AUTH_REQUIRED: "true"
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET}
AUTH_ISSUER: https://auth.shnt.top
AUTH_PORTAL_URL: https://auth.shnt.top
PUBLIC_BASE_URL: https://fw.example.com
EVOFW_ENROLL_SEED: ${EVOFW_ENROLL_SEED}
STATIC_DIR: /app/static
volumes:
- evofw-data:/data
ports:
- "8080:8080"
volumes:
evofw-data:
+6 -3
View File
@@ -1,5 +1,8 @@
# Документация EvoFirewall
- Интеграция с auth-portal — TODO (`docs/integrate-auth-portal.md`)
- Интеграция с EvoBGP (prefix source) — TODO (`docs/integrate-evobgp.md`)
- UI design contract — TODO (surface `frame`, kit `reui-kit/`)
- [Архитектура](architecture.md)
- [Auth-portal](integrate-auth-portal.md)
- [EvoBGP](integrate-evobgp.md)
- [Agents](agents.md)
- [UI design contract](ui-design-contract.md)
- [OpenAPI](openapi.yaml)
+28
View File
@@ -0,0 +1,28 @@
# Agents
## Linux
```bash
curl -fsSL https://<cp>/v1/agent/install.sh | \
EVOFW_CP_URL=https://<cp> \
EVOFW_SEED=<seed> \
EVOFW_CLIENT_NAME="web-01" \
bash
```
Файлы: `/etc/evofw/agent.conf`, `/usr/local/sbin/evofw-firewall.sh`, timer `evofw-firewall.timer` (default 1min).
Backend auto-detect: nft → ipset → iptables.
Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set.
## MikroTik
Скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, import. Scheduler каждую минуту тянет policy. Настройте filter на address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
## Force sync
```bash
sudo rm -f /var/lib/evofw/last_hash
sudo /usr/local/sbin/evofw-firewall.sh
```
+33
View File
@@ -0,0 +1,33 @@
# Архитектура EvoFirewall
Централизованный control plane для firewall-агентов (Linux nft/ipset, MikroTik address-list).
## Компоненты
| Компонент | Путь | Роль |
|-----------|------|------|
| Web SPA | `apps/web` | ReUI Frame, TanStack Router/Query |
| API | `apps/api` | Fastify 5, JWT + agent tokens |
| DB | `packages/db` | Drizzle + SQLite WAL |
| Shared | `packages/shared` | Zod-контракты, RBAC helpers |
| UI | `packages/ui` | shadcn primitives `@evofw/ui` |
| Agents | `apps/api/src/agent-scripts` | install.sh, sync, MikroTik RSC |
## Потоки
1. **Enroll**`POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
2. **Approve** — UI/API → status approved
3. **Policy**`GET /v1/agent/policy` → deny/allow CIDRs + mode + hash
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
## Политика
- `blacklist` — default accept, apply deny set
- `whitelist` — default drop, apply allow set (+ lo/established на Linux)
- Overrides и clone-from бампят `policy_generation`
## Auth
- Portal SSO app id **`fw`**, permissions `fw:*`
- Agent bearer token (sha256 hash в БД)
+44
View File
@@ -0,0 +1,44 @@
# Интеграция auth-portal ↔ EvoFirewall
App id: **`fw`**.
## Flow
```
Browser → EvoFirewall UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ /auth/callback сохраняет token
→ API Authorization: Bearer <JWT>
```
## Permissions
| Permission | UI |
|------------|-----|
| `fw:dashboard:read` | `/` |
| `fw:agents:read` / `write` | `/agents` |
| `fw:lists:read` / `write` | `/lists` |
| `fw:policies:read` / `write` | `/rules`, overrides |
| `fw:stats:read` | `/stats` |
| `fw:settings:admin` | `/settings`, install-context |
## Env
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
PUBLIC_BASE_URL=https://fw.example.com
EVOFW_ENROLL_SEED=<hex/seed>
```
```env
# apps/web/.env.local
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=https://auth.shnt.top
```
В portal Admin → Apps выдайте app `fw` и нужные `fw:*`. URL в App Switcher: origin EvoFirewall.
+21
View File
@@ -0,0 +1,21 @@
# Интеграция EvoBGP → EvoFirewall
EvoFirewall использует EvoBGP как **источник префиксов** для списков типа `evobgp_community`.
## Настройка
В UI Settings или `settings` table:
- `evobgp_api_url` — base URL EvoBGP API
- `evobgp_api_token` — API key (viewer+)
При refresh списка:
1. `GET {api}/v1/directories/communities/{id}/prefixes` (если доступен)
2. fallback `GET {api}/v1/lookup?q={community_id}`
## Список
Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries и бампит generation агентов.
Firewall-подсистема в EvoBGP **удалена** (hard cutover) — клиенты переустанавливаются на EvoFirewall agents.
+80
View File
@@ -0,0 +1,80 @@
openapi: 3.0.3
info:
title: EvoFirewall API
version: 0.1.0
description: Centralized firewall control plane
paths:
/health:
get:
summary: Liveness
responses:
'200':
description: OK
/api/v1/dashboard:
get:
summary: Dashboard KPI
security: [{ bearerAuth: [] }]
responses:
'200':
description: Stats
/api/v1/agents:
get:
summary: List agents
security: [{ bearerAuth: [] }]
responses:
'200':
description: Agents
/api/v1/lists:
get:
summary: List IP lists
security: [{ bearerAuth: [] }]
responses:
'200':
description: Lists
post:
summary: Create IP list
security: [{ bearerAuth: [] }]
responses:
'200':
description: Created
/api/v1/rules:
get:
summary: List policy rules
security: [{ bearerAuth: [] }]
responses:
'200':
description: Rules
post:
summary: Create policy rule
security: [{ bearerAuth: [] }]
responses:
'200':
description: Created
/v1/agent/enroll:
post:
summary: Enroll agent (public + seed)
responses:
'201':
description: Pending agent
/v1/agent/policy:
get:
summary: Evaluated policy for agent
security: [{ agentToken: [] }]
responses:
'200':
description: Policy
/v1/agent/apply-report:
post:
summary: Apply report + packet stats
security: [{ agentToken: [] }]
responses:
'200':
description: OK
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
agentToken:
type: http
scheme: bearer
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: { url: 'data/app.db' },
})
View File
+109
View File
@@ -0,0 +1,109 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT,
platform TEXT NOT NULL DEFAULT 'linux',
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
policy_mode TEXT NOT NULL DEFAULT 'blacklist',
policy_generation INTEGER NOT NULL DEFAULT 1,
last_seen_at TEXT,
last_seen_ip TEXT,
last_apply_at TEXT,
last_apply_status TEXT,
last_apply_error TEXT,
last_apply_prefix_count INTEGER DEFAULT 0,
last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0,
last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0,
last_apply_kernel_method TEXT,
client_version TEXT,
settings_json TEXT NOT NULL DEFAULT '{}',
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
approved_at TEXT,
revoked_at TEXT,
CHECK (status IN ('pending', 'approved', 'revoked')),
CHECK (platform IN ('linux', 'mikrotik')),
CHECK (policy_mode IN ('blacklist', 'whitelist')),
CHECK (length(trim(name)) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_token_hash ON agents (token_hash);
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
CREATE TABLE IF NOT EXISTS ip_lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
content_hash TEXT,
refreshed_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (type IN ('static', 'json_url', 'domains', 'evobgp_community'))
);
CREATE TABLE IF NOT EXISTS ip_list_entries (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES ip_lists (id) ON DELETE CASCADE,
cidr TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ip_list_entries_list_cidr ON ip_list_entries (list_id, cidr);
CREATE TABLE IF NOT EXISTS policy_rules (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents (id) ON DELETE CASCADE,
priority INTEGER NOT NULL,
action TEXT NOT NULL,
list_id TEXT REFERENCES ip_lists (id) ON DELETE CASCADE,
cidr TEXT,
comment TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (action IN ('allow', 'deny')),
CHECK (priority >= 1 AND priority <= 10000)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rules_agent_priority ON policy_rules (agent_id, priority);
CREATE TABLE IF NOT EXISTS ip_overrides (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
cidr TEXT NOT NULL,
action TEXT NOT NULL,
comment TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (action IN ('allow', 'deny'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ip_overrides_agent_cidr ON ip_overrides (agent_id, cidr);
CREATE TABLE IF NOT EXISTS agent_stats_samples (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
packets_dropped INTEGER NOT NULL DEFAULT 0,
packets_accepted INTEGER NOT NULL DEFAULT 0,
prefix_count INTEGER NOT NULL DEFAULT 0,
kernel_method TEXT,
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_stats_agent_time ON agent_stats_samples (agent_id, recorded_at);
INSERT OR IGNORE INTO settings (key, value) VALUES ('enroll_seed', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('evobgp_api_url', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('evobgp_api_token', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('list_refresh_cron', '*/5 * * * *');
INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_sync_interval_sec', '60');
+21 -4
View File
@@ -1,13 +1,30 @@
{
"name": "@evofw/db",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"files": ["dist", "migrations", "package.json"],
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "echo \"db: scaffold — skip\"",
"lint": "echo \"db: scaffold — skip\""
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@evofw/shared": "workspace:*",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.1",
"tsup": "^8.5.0",
"typescript": "^5.8.3"
}
}
+73
View File
@@ -0,0 +1,73 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync, mkdirSync } from 'node:fs'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { schema } from './schema.js'
export type Sqlite = Database.Database
export type Db = ReturnType<typeof drizzle<typeof schema>>
const __dirname = dirname(fileURLToPath(import.meta.url))
export function resolveDatabasePath(databaseUrl: string): string {
const url = databaseUrl.startsWith('sqlite:')
? databaseUrl.slice('sqlite:'.length)
: databaseUrl
return url
}
export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
const path = resolveDatabasePath(databaseUrl)
const dir = dirname(path)
if (dir && dir !== '.') {
try {
mkdirSync(dir, { recursive: true })
} catch {
/* exists */
}
}
const sqlite = new Database(path)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('synchronous = NORMAL')
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function createMemoryDb(): { db: Db; sqlite: Sqlite } {
const sqlite = new Database(':memory:')
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function runMigrations(sqlite: Sqlite): void {
const migrationsDir = join(__dirname, '..', 'migrations')
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort()
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
)
for (const file of files) {
const applied = sqlite
.prepare('SELECT 1 FROM _migrations WHERE name = ?')
.get(file)
if (applied) continue
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
sqlite.exec(sql)
sqlite.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file)
}
}
export function healthCheck(sqlite: Sqlite): void {
sqlite.prepare('SELECT 1').get()
}
+3 -1
View File
@@ -1 +1,3 @@
export {}
export * from './schema.js'
export * from './client.js'
export * from './repositories/index.js'
+289
View File
@@ -0,0 +1,289 @@
import { eq, and, desc, isNull, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agents,
ipLists,
ipListEntries,
policyRules,
ipOverrides,
agentStatsSamples,
settings,
} from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(
db: Db,
row: typeof agents.$inferInsert,
) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(ipListEntries)
.values({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})
.run()
}
}
export function listPolicyRules(db: Db, agentId?: string | null) {
if (agentId === undefined) {
return db.select().from(policyRules).orderBy(policyRules.priority).all()
}
if (agentId === null) {
return db
.select()
.from(policyRules)
.where(isNull(policyRules.agentId))
.orderBy(policyRules.priority)
.all()
}
return db
.select()
.from(policyRules)
.where(eq(policyRules.agentId, agentId))
.orderBy(policyRules.priority)
.all()
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
return getPolicyRule(db, row.id)
}
export function deletePolicyRule(db: Db, id: string) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
}
export function insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
db.delete(policyRules).where(eq(policyRules.agentId, targetAgentId)).run()
const rules = listPolicyRules(db, sourceAgentId)
for (const r of rules) {
db.insert(policyRules)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
priority: r.priority,
action: r.action,
listId: r.listId,
cidr: r.cidr,
comment: r.comment,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.run()
}
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
policyMode: source.policyMode,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
export const repos = {
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
bumpAgentGeneration,
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
replaceIpListEntries,
listPolicyRules,
getPolicyRule,
insertPolicyRule,
deletePolicyRule,
listOverrides,
insertOverride,
deleteOverride,
insertStatsSample,
listStatsSamples,
listRecentStats,
getSetting,
setSetting,
listSettings,
cloneRulesFrom,
}
+152
View File
@@ -0,0 +1,152 @@
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull().default(''),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
})
export const agents = sqliteTable(
'agents',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
hostname: text('hostname'),
platform: text('platform').notNull().default('linux'), // linux | mikrotik
tokenPrefix: text('token_prefix').notNull(),
tokenHash: text('token_hash').notNull(),
status: text('status').notNull().default('pending'), // pending | approved | revoked
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
policyGeneration: integer('policy_generation').notNull().default(1),
lastSeenAt: text('last_seen_at'),
lastSeenIp: text('last_seen_ip'),
lastApplyAt: text('last_apply_at'),
lastApplyStatus: text('last_apply_status'),
lastApplyError: text('last_apply_error'),
lastApplyPrefixCount: integer('last_apply_prefix_count').default(0),
lastApplyPacketsDropped: integer('last_apply_packets_dropped').notNull().default(0),
lastApplyPacketsAccepted: integer('last_apply_packets_accepted').notNull().default(0),
lastApplyKernelMethod: text('last_apply_kernel_method'),
clientVersion: text('client_version'),
settingsJson: text('settings_json').notNull().default('{}'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
approvedAt: text('approved_at'),
revokedAt: text('revoked_at'),
},
(t) => ({
tokenHashIdx: uniqueIndex('idx_agents_token_hash').on(t.tokenHash),
statusIdx: index('idx_agents_status').on(t.status),
}),
)
export const ipLists = sqliteTable('ip_lists', {
id: text('id').primaryKey(),
name: text('name').notNull(),
type: text('type').notNull(), // static | json_url | domains | evobgp_community
configJson: text('config_json').notNull().default('{}'),
contentHash: text('content_hash'),
refreshedAt: text('refreshed_at'),
lastError: text('last_error'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
})
export const ipListEntries = sqliteTable(
'ip_list_entries',
{
id: text('id').primaryKey(),
listId: text('list_id')
.notNull()
.references(() => ipLists.id, { onDelete: 'cascade' }),
cidr: text('cidr').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
listCidr: uniqueIndex('idx_ip_list_entries_list_cidr').on(t.listId, t.cidr),
}),
)
export const policyRules = sqliteTable(
'policy_rules',
{
id: text('id').primaryKey(),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), // null = tenant default
priority: integer('priority').notNull(),
action: text('action').notNull(), // allow | deny
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
cidr: text('cidr'),
comment: text('comment'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentPriority: uniqueIndex('idx_policy_rules_agent_priority').on(t.agentId, t.priority),
}),
)
export const ipOverrides = sqliteTable(
'ip_overrides',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
cidr: text('cidr').notNull(),
action: text('action').notNull(), // allow | deny
comment: text('comment'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentCidr: uniqueIndex('idx_ip_overrides_agent_cidr').on(t.agentId, t.cidr),
}),
)
export const agentStatsSamples = sqliteTable(
'agent_stats_samples',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
packetsDropped: integer('packets_dropped').notNull().default(0),
packetsAccepted: integer('packets_accepted').notNull().default(0),
prefixCount: integer('prefix_count').notNull().default(0),
kernelMethod: text('kernel_method'),
recordedAt: text('recorded_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentTime: index('idx_agent_stats_agent_time').on(t.agentId, t.recordedAt),
}),
)
export const schema = {
settings,
agents,
ipLists,
ipListEntries,
policyRules,
ipOverrides,
agentStatsSamples,
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+18 -5
View File
@@ -1,14 +1,27 @@
{
"name": "@evofw/shared",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"files": ["dist", "package.json"],
"exports": {
".": "./src/index.ts",
"./contracts/*": "./src/contracts/*.ts"
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "echo \"shared: scaffold — skip\"",
"lint": "echo \"shared: scaffold — skip\""
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
+147
View File
@@ -0,0 +1,147 @@
import { z } from 'zod'
export const agentPlatformSchema = z.enum(['linux', 'mikrotik'])
export const agentStatusSchema = z.enum(['pending', 'approved', 'revoked'])
export const policyModeSchema = z.enum(['blacklist', 'whitelist'])
export const policyActionSchema = z.enum(['allow', 'deny'])
export const ipListTypeSchema = z.enum([
'static',
'json_url',
'domains',
'evobgp_community',
])
export const agentSchema = z.object({
id: z.string(),
name: z.string(),
hostname: z.string().nullable().optional(),
platform: agentPlatformSchema,
token_prefix: z.string(),
status: agentStatusSchema,
policy_mode: policyModeSchema,
policy_generation: z.number().int(),
last_seen_at: z.string().nullable().optional(),
last_seen_ip: z.string().nullable().optional(),
last_apply_at: z.string().nullable().optional(),
last_apply_status: z.string().nullable().optional(),
last_apply_error: z.string().nullable().optional(),
last_apply_prefix_count: z.number().int().nullable().optional(),
last_apply_packets_dropped: z.number().int().optional(),
last_apply_packets_accepted: z.number().int().optional(),
last_apply_kernel_method: z.string().nullable().optional(),
client_version: z.string().nullable().optional(),
settings_json: z.string().optional(),
created_at: z.string(),
approved_at: z.string().nullable().optional(),
revoked_at: z.string().nullable().optional(),
})
export const ipListSchema = z.object({
id: z.string(),
name: z.string(),
type: ipListTypeSchema,
config_json: z.string(),
content_hash: z.string().nullable().optional(),
refreshed_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
entry_count: z.number().int().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export const policyRuleSchema = z.object({
id: z.string(),
agent_id: z.string().nullable().optional(),
priority: z.number().int(),
action: policyActionSchema,
list_id: z.string().nullable().optional(),
cidr: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
})
export const ipOverrideSchema = z.object({
id: z.string(),
agent_id: z.string(),
cidr: z.string(),
action: policyActionSchema,
comment: z.string().nullable().optional(),
created_at: z.string(),
})
export const createIpListBodySchema = z.object({
name: z.string().min(1),
type: ipListTypeSchema,
config: z.record(z.string(), z.unknown()).optional(),
entries: z.array(z.string()).optional(),
})
export const createPolicyRuleBodySchema = z.object({
agent_id: z.string().nullable().optional(),
priority: z.number().int().min(1).max(10000),
action: policyActionSchema,
list_id: z.string().nullable().optional(),
cidr: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
})
export const createOverrideBodySchema = z.object({
cidr: z.string().min(1),
action: policyActionSchema,
comment: z.string().nullable().optional(),
})
export const patchAgentBodySchema = z.object({
name: z.string().min(1).optional(),
policy_mode: policyModeSchema.optional(),
settings: z.record(z.string(), z.unknown()).optional(),
})
export const cloneFromBodySchema = z.object({
include_overrides: z.boolean().optional().default(false),
})
export const enrollBodySchema = z.object({
name: z.string().min(1),
hostname: z.string().optional(),
platform: agentPlatformSchema.optional().default('linux'),
token: z.string().min(16),
client_version: z.string().optional(),
})
export const applyReportBodySchema = z.object({
status: z.string(),
prefix_count: z.number().int().optional(),
packets_dropped: z.number().int().optional(),
packets_accepted: z.number().int().optional(),
kernel_method: z.string().optional(),
error: z.string().optional(),
source: z.string().optional(),
})
export const agentPolicySchema = z.object({
generation: z.number().int(),
hash: z.string(),
policy_mode: policyModeSchema,
deny_cidrs: z.array(z.string()),
allow_cidrs: z.array(z.string()),
sync_interval_sec: z.number().int(),
})
export const dashboardStatsSchema = z.object({
agents_total: z.number().int(),
agents_approved: z.number().int(),
agents_online: z.number().int(),
agents_pending: z.number().int(),
packets_dropped: z.number().int(),
packets_accepted: z.number().int(),
lists_total: z.number().int(),
})
export type Agent = z.infer<typeof agentSchema>
export type IpList = z.infer<typeof ipListSchema>
export type PolicyRule = z.infer<typeof policyRuleSchema>
export type IpOverride = z.infer<typeof ipOverrideSchema>
export type AgentPolicy = z.infer<typeof agentPolicySchema>
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
+2 -1
View File
@@ -1 +1,2 @@
export {}
export * from './contracts.js'
export * from './permissions.js'
+57
View File
@@ -0,0 +1,57 @@
export type PermissionAction = 'read' | 'write' | 'admin'
/** Hierarchy: admin ⊃ write ⊃ read within the same section. */
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function permissionForRequest(
method: string,
url: string,
): string | null {
const path = url.split('?')[0] ?? url
const m = method.toUpperCase()
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
if (path.startsWith('/api/v1/agents')) {
return write ? 'fw:agents:write' : 'fw:agents:read'
}
if (path.startsWith('/api/v1/lists')) {
return write ? 'fw:lists:write' : 'fw:lists:read'
}
if (path.startsWith('/api/v1/rules') || path.startsWith('/api/v1/policies')) {
return write ? 'fw:policies:write' : 'fw:policies:read'
}
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
return 'fw:stats:read'
}
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
return write ? 'fw:settings:admin' : 'fw:settings:read'
}
return null
}
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin: boolean
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+31 -6
View File
@@ -1,16 +1,41 @@
{
"name": "@evofw/ui",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts",
"./components/*": "./src/components/*.tsx"
"./hooks/*": "./src/hooks/*.ts"
},
"scripts": {
"build": "echo \"ui: scaffold — skip\"",
"lint": "echo \"ui: scaffold — skip\""
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react-day-picker": "^9.4.0",
"recharts": "^2.15.0",
"sonner": "^1.7.0",
"tailwind-merge": "^3.0.0",
"tw-animate-css": "^1.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.3"
}
}
View File
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@evofw/ui/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+54
View File
@@ -0,0 +1,54 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
success:
"bg-success/10 text-success focus-visible:ring-success/20 dark:bg-success/15 dark:focus-visible:ring-success/30 [a]:hover:bg-success/15",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evofw/ui/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
import { Separator } from "@evofw/ui/components/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@evofw/ui/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+400
View File
@@ -0,0 +1,400 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@evofw/ui/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ReactNode
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
const containerRef = React.useRef<HTMLDivElement>(null)
const [size, setSize] = React.useState(initialDimension)
React.useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const update = () => {
const width = Math.max(1, Math.floor(el.clientWidth))
const height = Math.max(1, Math.floor(el.clientHeight))
setSize((prev) =>
prev.width === width && prev.height === height
? prev
: { width, height },
)
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Recharts 3 ResponsiveContainer часто оставляет inner 0×0 (см. debug dashboard).
// Рендерим chart с явными width/height по размеру контейнера.
const sizedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
return React.cloneElement(
child as React.ReactElement<{ width?: number; height?: number }>,
{ width: size.width, height: size.height },
)
})
return (
<ChartContext.Provider value={{ config }}>
<div
ref={containerRef}
data-slot="chart"
data-chart={chartId}
className={cn(
"relative block w-full min-h-0 text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
{sizedChildren}
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+27
View File
@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@evofw/ui/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@evofw/ui/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
import { Label } from "@evofw/ui/components/label"
import { Separator } from "@evofw/ui/components/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { Input } from "@evofw/ui/components/input"
import { Textarea } from "@evofw/ui/components/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@evofw/ui/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@evofw/ui/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@evofw/ui/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
@@ -0,0 +1,52 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@evofw/ui/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@evofw/ui/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+23
View File
@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evofw/ui/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+136
View File
@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+721
View File
@@ -0,0 +1,721 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "../hooks/use-mobile"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { Input } from "@evofw/ui/components/input"
import { Separator } from "@evofw/ui/components/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@evofw/ui/components/sheet"
import { Skeleton } from "@evofw/ui/components/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@evofw/ui/components/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "240px"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@evofw/ui/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+47
View File
@@ -0,0 +1,47 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "@evofw/ui/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+30
View File
@@ -0,0 +1,30 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@evofw/ui/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@evofw/ui/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

Some files were not shown because too many files have changed in this diff Show More