feat: Add new tools and configurations for Castle platform
- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`. - Added `pyrightconfig.json` for Python type checking configuration. - Expanded `recommendations.md` with detailed scaling recommendations and project management strategies. - Created shared `ruff.toml` for consistent linting across projects. - Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools. - Implemented `backup-collect` and `schedule` tools for system administration tasks. - Enhanced `search` functionality with indexing and querying capabilities using Tantivy. - Added comprehensive documentation for each tool, including usage examples and installation instructions.
This commit is contained in:
1
dashboard/.env
Normal file
1
dashboard/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
24
dashboard/.gitignore
vendored
Normal file
24
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
dashboard/README.md
Normal file
73
dashboard/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
dashboard/eslint.config.js
Normal file
23
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Castle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
dashboard/package.json
Normal file
39
dashboard/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
2492
dashboard/pnpm-lock.yaml
generated
Normal file
2492
dashboard/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
dashboard/src/App.tsx
Normal file
12
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider } from "react-router-dom"
|
||||
import { queryClient } from "@/lib/queryClient"
|
||||
import { router } from "@/router/routes"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
194
dashboard/src/components/AddComponent.tsx
Normal file
194
dashboard/src/components/AddComponent.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState } from "react"
|
||||
import { Plus, X } from "lucide-react"
|
||||
|
||||
const TEMPLATES: Record<string, Record<string, unknown>> = {
|
||||
service: {
|
||||
run: {
|
||||
runner: "python_uv_tool",
|
||||
tool: "",
|
||||
cwd: "",
|
||||
env: {},
|
||||
},
|
||||
expose: {
|
||||
http: {
|
||||
internal: { port: 9001 },
|
||||
health_path: "/health",
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
caddy: { path_prefix: "/" },
|
||||
},
|
||||
manage: {
|
||||
systemd: {},
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
install: {
|
||||
path: { alias: "" },
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
run: {
|
||||
runner: "command",
|
||||
argv: [""],
|
||||
cwd: "",
|
||||
},
|
||||
manage: {
|
||||
systemd: {},
|
||||
},
|
||||
},
|
||||
empty: {},
|
||||
}
|
||||
|
||||
interface AddComponentProps {
|
||||
onAdd: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||
existingNames: string[]
|
||||
}
|
||||
|
||||
export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [template, setTemplate] = useState("service")
|
||||
const [port, setPort] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const nameError =
|
||||
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
|
||||
? "lowercase letters, numbers, and hyphens"
|
||||
: existingNames.includes(name)
|
||||
? "already exists"
|
||||
: ""
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name || nameError) return
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
const config: Record<string, unknown> = JSON.parse(
|
||||
JSON.stringify(TEMPLATES[template] ?? {})
|
||||
)
|
||||
if (description) config.description = description
|
||||
|
||||
// Fill in template-specific fields
|
||||
if (template === "service") {
|
||||
const run = config.run as Record<string, unknown>
|
||||
run.tool = name
|
||||
run.cwd = name
|
||||
const proxy = (config.proxy as Record<string, Record<string, string>>).caddy
|
||||
proxy.path_prefix = `/${name}`
|
||||
if (port) {
|
||||
const expose = (config.expose as Record<string, Record<string, Record<string, number>>>)
|
||||
expose.http.internal.port = parseInt(port, 10)
|
||||
}
|
||||
} else if (template === "tool") {
|
||||
const install = (config.install as Record<string, Record<string, string>>).path
|
||||
install.alias = name
|
||||
}
|
||||
|
||||
await onAdd(name, config)
|
||||
setName("")
|
||||
setDescription("")
|
||||
setPort("")
|
||||
setOpen(false)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-2 p-4 border border-dashed border-[var(--border)] rounded-lg text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Add component
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">New component</h3>
|
||||
<button onClick={() => setOpen(false)} className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.toLowerCase())}
|
||||
placeholder="my-service"
|
||||
autoFocus
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
{nameError && <p className="text-xs text-red-400 mt-1">{nameError}</p>}
|
||||
</Field>
|
||||
|
||||
<Field label="Template">
|
||||
<select
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="service">Service (FastAPI + systemd + Caddy)</option>
|
||||
<option value="tool">Tool (PATH install)</option>
|
||||
<option value="worker">Worker (systemd, no HTTP)</option>
|
||||
<option value="empty">Empty</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description">
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this component do?"
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{template === "service" && (
|
||||
<Field label="Port">
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="9001"
|
||||
className="w-32 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || !!nameError || saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Plus size={14} /> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
dashboard/src/components/ComponentCard.tsx
Normal file
116
dashboard/src/components/ComponentCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: ComponentSummary
|
||||
health?: HealthStatus
|
||||
}
|
||||
|
||||
export function ComponentCard({ component, health }: ComponentCardProps) {
|
||||
const hasHttp = component.port != null
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
|
||||
const doAction = (action: string) => {
|
||||
mutate({ name: component.id, action })
|
||||
}
|
||||
|
||||
const isDown = health?.status === "down"
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<Link
|
||||
to={`/${component.id}`}
|
||||
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{component.id}
|
||||
</Link>
|
||||
{health ? (
|
||||
<HealthBadge status={health.status} latency={health.latency_ms} />
|
||||
) : hasHttp ? (
|
||||
<HealthBadge status="unknown" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-2">
|
||||
{component.roles.map((role) => (
|
||||
<RoleBadge key={role} role={role} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
<p className="text-sm text-[var(--muted)] mb-3">{component.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
{component.port && (
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Server size={12} />:{component.port}
|
||||
</span>
|
||||
)}
|
||||
{component.runner && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Terminal size={12} />
|
||||
{component.runner}
|
||||
</span>
|
||||
)}
|
||||
{component.proxy_path && (
|
||||
<a
|
||||
href={component.proxy_path + "/"}
|
||||
className="flex items-center gap-1 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{component.proxy_path}
|
||||
</a>
|
||||
)}
|
||||
{component.port && (
|
||||
<a
|
||||
href={`http://localhost:${component.port}/docs`}
|
||||
className="text-[var(--primary)] hover:underline"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.managed && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => doAction("start")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => doAction("restart")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
<button
|
||||
onClick={() => doAction("stop")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
dashboard/src/components/ComponentEditor.tsx
Normal file
45
dashboard/src/components/ComponentEditor.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import { ComponentFields } from "./ComponentFields"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
|
||||
interface ComponentEditorProps {
|
||||
component: ComponentDetail
|
||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||
onDelete: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function ComponentEditor({ component, onSave, onDelete }: ComponentEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
<span className="font-semibold">{component.id}</span>
|
||||
<span className="text-sm text-[var(--muted)]">{component.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{component.roles.map((r) => (
|
||||
<RoleBadge key={r} role={r} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--border)] p-4">
|
||||
<ComponentFields
|
||||
component={component}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
254
dashboard/src/components/ComponentFields.tsx
Normal file
254
dashboard/src/components/ComponentFields.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Check, Loader2, Save, Trash2 } from "lucide-react"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import { SecretsEditor } from "./SecretsEditor"
|
||||
|
||||
interface ComponentFieldsProps {
|
||||
component: ComponentDetail
|
||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||
onDelete?: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
|
||||
|
||||
export function ComponentFields({ component, onSave, onDelete }: ComponentFieldsProps) {
|
||||
const m = component.manifest
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const allEnv: Record<string, string> =
|
||||
((m.run as Record<string, unknown>)?.env as Record<string, string>) ?? {}
|
||||
|
||||
// Split into plain env vars and secret references
|
||||
const { initialEnv, initialSecrets } = useMemo(() => {
|
||||
const env: Record<string, string> = {}
|
||||
const secrets: Record<string, string> = {}
|
||||
for (const [key, val] of Object.entries(allEnv)) {
|
||||
const match = SECRET_RE.exec(val)
|
||||
if (match) {
|
||||
secrets[key] = match[1]
|
||||
} else {
|
||||
env[key] = val
|
||||
}
|
||||
}
|
||||
return { initialEnv: env, initialSecrets: secrets }
|
||||
}, [])
|
||||
|
||||
const [runEnv, setRunEnv] = useState<Record<string, string>>(initialEnv)
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>(initialSecrets)
|
||||
|
||||
const [description, setDescription] = useState(m.description as string ?? "")
|
||||
const [port, setPort] = useState(
|
||||
String(
|
||||
((m.expose as Record<string, unknown>)?.http as Record<string, unknown>)
|
||||
?.internal as Record<string, unknown>
|
||||
? (((m.expose as Record<string, unknown>)?.http as Record<string, unknown>)
|
||||
?.internal as Record<string, number>)?.port ?? ""
|
||||
: ""
|
||||
)
|
||||
)
|
||||
const [proxyPath, setProxyPath] = useState(
|
||||
((m.proxy as Record<string, unknown>)?.caddy as Record<string, string>)?.path_prefix ?? ""
|
||||
)
|
||||
const [healthPath, setHealthPath] = useState(
|
||||
((m.expose as Record<string, unknown>)?.http as Record<string, string>)?.health_path ?? ""
|
||||
)
|
||||
|
||||
const runner = (m.run as Record<string, unknown>)?.runner as string | undefined
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
try {
|
||||
const config: Record<string, unknown> = { ...m }
|
||||
delete config.id
|
||||
delete config.roles
|
||||
config.description = description || undefined
|
||||
|
||||
// Merge plain env + secret references back together
|
||||
if (config.run && typeof config.run === "object") {
|
||||
const mergedEnv: Record<string, string> = { ...runEnv }
|
||||
for (const [envKey, secretName] of Object.entries(secrets)) {
|
||||
mergedEnv[envKey] = `\${secret:${secretName}}`
|
||||
}
|
||||
config.run = { ...config.run as Record<string, unknown>, env: mergedEnv }
|
||||
}
|
||||
|
||||
if (port) {
|
||||
const portNum = parseInt(port, 10)
|
||||
if (!isNaN(portNum)) {
|
||||
config.expose = {
|
||||
http: {
|
||||
internal: { port: portNum },
|
||||
...(healthPath ? { health_path: healthPath } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyPath) {
|
||||
config.proxy = { caddy: { path_prefix: proxyPath } }
|
||||
} else {
|
||||
delete config.proxy
|
||||
}
|
||||
|
||||
await onSave(component.id, config)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="Description">
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{runner && (
|
||||
<Field label="Runner">
|
||||
<span className="text-sm font-mono text-[var(--muted)]">
|
||||
{runner}
|
||||
{(m.run as Record<string, string>)?.tool && (
|
||||
<> · {(m.run as Record<string, string>).tool}</>
|
||||
)}
|
||||
</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(component.managed || port) && (
|
||||
<Field label="Port">
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="e.g. 9001"
|
||||
className="w-32 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(component.managed || healthPath) && (
|
||||
<Field label="Health path">
|
||||
<input
|
||||
value={healthPath}
|
||||
onChange={(e) => setHealthPath(e.target.value)}
|
||||
placeholder="/health"
|
||||
className="w-48 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Proxy path">
|
||||
<input
|
||||
value={proxyPath}
|
||||
onChange={(e) => setProxyPath(e.target.value)}
|
||||
placeholder="/my-service"
|
||||
className="w-48 bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{runner && (
|
||||
<Field label="Environment">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(runEnv).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
value={key}
|
||||
readOnly
|
||||
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono text-[var(--muted)]"
|
||||
/>
|
||||
<span className="text-[var(--muted)]">=</span>
|
||||
<input
|
||||
value={val}
|
||||
onChange={(e) =>
|
||||
setRunEnv((prev) => ({ ...prev, [key]: e.target.value }))
|
||||
}
|
||||
className="flex-1 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRunEnv((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="text-red-400 hover:text-red-300 p-0.5"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = prompt("Variable name:")
|
||||
if (key) setRunEnv((prev) => ({ ...prev, [key]: "" }))
|
||||
}}
|
||||
className="text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
+ Add variable
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{runner && (
|
||||
<Field label="Secrets">
|
||||
<SecretsEditor secrets={secrets} onSecretsChange={setSecrets} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Systemd">
|
||||
<span className="text-sm text-[var(--muted)]">
|
||||
{component.managed ? "Yes" : "No"}
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
||||
{onDelete ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete component "${component.id}" from castle.yaml?`)) {
|
||||
onDelete(component.id)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={12} /> Remove component
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : saved ? (
|
||||
<Check size={14} />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
{saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4">
|
||||
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
dashboard/src/components/ComponentGrid.tsx
Normal file
56
dashboard/src/components/ComponentGrid.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { ComponentCard } from "./ComponentCard"
|
||||
|
||||
const ROLE_ORDER = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
service: "Services",
|
||||
tool: "Tools",
|
||||
worker: "Workers",
|
||||
job: "Jobs",
|
||||
frontend: "Frontends",
|
||||
remote: "Remote",
|
||||
containerized: "Containers",
|
||||
}
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: ComponentSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
// Group by primary role
|
||||
const groups = new Map<string, ComponentSummary[]>()
|
||||
for (const comp of components) {
|
||||
const primary = comp.roles[0] ?? "tool"
|
||||
const list = groups.get(primary) ?? []
|
||||
list.push(comp)
|
||||
groups.set(primary, list)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{ROLE_ORDER.map((role) => {
|
||||
const items = groups.get(role)
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<section key={role}>
|
||||
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
||||
{ROLE_LABELS[role] ?? role}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((comp) => (
|
||||
<ComponentCard
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
health={statusMap.get(comp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
dashboard/src/components/HealthBadge.tsx
Normal file
32
dashboard/src/components/HealthBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface HealthBadgeProps {
|
||||
status: "up" | "down" | "unknown"
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
export function HealthBadge({ status, latency }: HealthBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full",
|
||||
status === "up" && "bg-green-800/50 text-green-300",
|
||||
status === "down" && "bg-red-800/50 text-red-300",
|
||||
status === "unknown" && "bg-gray-700/50 text-gray-400",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
status === "up" && "bg-green-400",
|
||||
status === "down" && "bg-red-400",
|
||||
status === "unknown" && "bg-gray-500",
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
{latency != null && status === "up" && (
|
||||
<span className="text-gray-500 ml-0.5">{latency}ms</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
71
dashboard/src/components/LogViewer.tsx
Normal file
71
dashboard/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
|
||||
interface LogViewerProps {
|
||||
name: string
|
||||
lines?: number
|
||||
follow?: boolean
|
||||
}
|
||||
|
||||
export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
// Static fetch
|
||||
apiClient
|
||||
.get<{ lines: string[] }>(`/logs/${name}?n=${lines}`)
|
||||
.then((data) => setLogs(data.lines))
|
||||
return
|
||||
}
|
||||
|
||||
// SSE follow
|
||||
const url = apiClient.streamUrl(`/logs/${name}?n=${lines}&follow=true`)
|
||||
const es = new EventSource(url)
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
|
||||
es.onmessage = (e) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, e.data]
|
||||
// Keep last 500 lines in memory
|
||||
return next.length > 500 ? next.slice(-500) : next
|
||||
})
|
||||
}
|
||||
|
||||
es.onerror = () => setConnected(false)
|
||||
|
||||
return () => es.close()
|
||||
}, [name, lines, follow])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [logs])
|
||||
|
||||
return (
|
||||
<div className="bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
|
||||
<span>logs: {name}</span>
|
||||
{follow && (
|
||||
<span className={connected ? "text-green-400" : "text-red-400"}>
|
||||
{connected ? "streaming" : "disconnected"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-96 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<span className="text-[var(--muted)]">No logs yet</span>
|
||||
) : (
|
||||
logs.map((line, i) => (
|
||||
<div key={i} className="leading-5 hover:bg-white/5">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
dashboard/src/components/RoleBadge.tsx
Normal file
24
dashboard/src/components/RoleBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
service: "bg-green-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
worker: "bg-blue-500 text-white",
|
||||
job: "bg-purple-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
remote: "bg-gray-600 text-gray-200",
|
||||
containerized: "bg-orange-600 text-black",
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
roleColors[role] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
160
dashboard/src/components/SecretsEditor.tsx
Normal file
160
dashboard/src/components/SecretsEditor.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Check, Eye, EyeOff, Loader2, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
|
||||
interface SecretsEditorProps {
|
||||
/** Current secret references: { ENV_VAR_NAME: "SECRET_FILE_NAME" } */
|
||||
secrets: Record<string, string>
|
||||
onSecretsChange: (secrets: Record<string, string>) => void
|
||||
}
|
||||
|
||||
interface SecretState {
|
||||
value: string
|
||||
original: string
|
||||
visible: boolean
|
||||
saving: boolean
|
||||
saved: boolean
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) {
|
||||
const [states, setStates] = useState<Record<string, SecretState>>({})
|
||||
|
||||
// Load secret values when the secret list changes
|
||||
useEffect(() => {
|
||||
for (const [envKey, secretName] of Object.entries(secrets)) {
|
||||
if (states[envKey]?.loaded) continue
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: {
|
||||
value: "", original: "", visible: false,
|
||||
saving: false, saved: false, loaded: false,
|
||||
},
|
||||
}))
|
||||
apiClient
|
||||
.get<{ value: string }>(`/secrets/${secretName}`)
|
||||
.then((data) => {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: { ...prev[envKey], value: data.value, original: data.value, loaded: true },
|
||||
}))
|
||||
})
|
||||
.catch(() => {
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: { ...prev[envKey], loaded: true },
|
||||
}))
|
||||
})
|
||||
}
|
||||
}, [Object.keys(secrets).join(",")])
|
||||
|
||||
const handleSave = async (envKey: string) => {
|
||||
const s = states[envKey]
|
||||
const secretName = secrets[envKey]
|
||||
if (!s || !secretName || s.value === s.original) return
|
||||
|
||||
setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saving: true } }))
|
||||
try {
|
||||
await apiClient.put(`/secrets/${secretName}`, { value: s.value })
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: { ...prev[envKey], saving: false, saved: true, original: s.value },
|
||||
}))
|
||||
setTimeout(() => {
|
||||
setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saved: false } }))
|
||||
}, 2000)
|
||||
} catch {
|
||||
setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], saving: false } }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
const envKey = prompt("Environment variable name (e.g. MY_API_KEY):")
|
||||
if (!envKey) return
|
||||
const secretName = prompt("Secret file name (stored in ~/.castle/secrets/):", envKey)
|
||||
if (!secretName) return
|
||||
onSecretsChange({ ...secrets, [envKey]: secretName })
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: {
|
||||
value: "", original: "", visible: true,
|
||||
saving: false, saved: false, loaded: true,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemove = (envKey: string) => {
|
||||
const next = { ...secrets }
|
||||
delete next[envKey]
|
||||
onSecretsChange(next)
|
||||
setStates((prev) => {
|
||||
const n = { ...prev }
|
||||
delete n[envKey]
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(secrets).map(([envKey, secretName]) => {
|
||||
const s = states[envKey]
|
||||
const dirty = s ? s.value !== s.original : false
|
||||
|
||||
return (
|
||||
<div key={envKey} className="flex items-center gap-2">
|
||||
<span className="w-56 shrink-0 text-xs font-mono text-[var(--muted)] truncate" title={`${envKey} → ${secretName}`}>
|
||||
{envKey}
|
||||
</span>
|
||||
<div className="flex-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type={s?.visible ? "text" : "password"}
|
||||
value={s?.loaded ? s.value : ""}
|
||||
placeholder={s?.loaded ? "(empty)" : "loading..."}
|
||||
onChange={(e) =>
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: { ...prev[envKey], value: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="flex-1 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStates((prev) => ({
|
||||
...prev,
|
||||
[envKey]: { ...prev[envKey], visible: !prev[envKey]?.visible },
|
||||
}))
|
||||
}
|
||||
className="p-1 text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
{s?.visible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSave(envKey)}
|
||||
disabled={!dirty || s?.saving}
|
||||
className="p-1 text-blue-400 hover:text-blue-300 disabled:opacity-30"
|
||||
>
|
||||
{s?.saving ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : s?.saved ? (
|
||||
<Check size={12} className="text-green-400" />
|
||||
) : (
|
||||
<Save size={12} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(envKey)}
|
||||
className="p-1 text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button onClick={handleAdd} className="text-xs text-[var(--primary)] hover:underline">
|
||||
<Plus size={10} className="inline mr-1" />Add secret
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
dashboard/src/index.css
Normal file
19
dashboard/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0f1117;
|
||||
--foreground: #e1e4e8;
|
||||
--card: #161b22;
|
||||
--card-foreground: #e1e4e8;
|
||||
--border: #30363d;
|
||||
--muted: #8b949e;
|
||||
--primary: #58a6ff;
|
||||
--destructive: #da3633;
|
||||
--success: #238636;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
11
dashboard/src/lib/queryClient.ts
Normal file
11
dashboard/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
6
dashboard/src/lib/utils.ts
Normal file
6
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
152
dashboard/src/pages/ComponentDetail.tsx
Normal file
152
dashboard/src/pages/ComponentDetail.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react"
|
||||
import { useParams, Link, useNavigate } from "react-router-dom"
|
||||
import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
import { useComponent, useStatus, useServiceAction, useEventStream } from "@/services/api/hooks"
|
||||
import { ComponentFields } from "@/components/ComponentFields"
|
||||
import { HealthBadge } from "@/components/HealthBadge"
|
||||
import { LogViewer } from "@/components/LogViewer"
|
||||
import { RoleBadge } from "@/components/RoleBadge"
|
||||
|
||||
export function ComponentDetailPage() {
|
||||
useEventStream()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
|
||||
const { data: statusResp } = useStatus()
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||
const isDown = health?.status === "down"
|
||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||
|
||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiClient.put(`/config/components/${compName}`, { config })
|
||||
setMessage({ type: "ok", text: "Saved to castle.yaml" })
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setMessage({ type: "error", text: msg })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (compName: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/config/components/${compName}`)
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
navigate("/")
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setMessage({ type: "error", text: msg })
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 text-[var(--muted)]">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !component) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-4">
|
||||
<ArrowLeft size={16} /> Back
|
||||
</Link>
|
||||
<p className="text-red-400">Component not found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
|
||||
<ArrowLeft size={16} /> Back
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">{component.id}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{health && <HealthBadge status={health.status} latency={health.latency_ms} />}
|
||||
{component.managed && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: component.id, action: "start" })}
|
||||
disabled={isPending}
|
||||
className="p-1.5 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => mutate({ name: component.id, action: "restart" })}
|
||||
disabled={isPending}
|
||||
className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: component.id, action: "stop" })}
|
||||
disabled={isPending}
|
||||
className="p-1.5 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-6">
|
||||
{component.roles.map((role) => (
|
||||
<RoleBadge key={role} role={role} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 px-3 py-2 rounded text-sm ${
|
||||
message.type === "ok"
|
||||
? "bg-green-900/30 text-green-300 border border-green-800"
|
||||
: "bg-red-900/30 text-red-300 border border-red-800"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{message.type === "ok" && <Check size={14} />}
|
||||
{message.text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
|
||||
Configuration
|
||||
</h2>
|
||||
<ComponentFields
|
||||
component={component}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{component.managed && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">Logs</h2>
|
||||
<LogViewer name={component.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
dashboard/src/pages/ConfigEditor.tsx
Normal file
146
dashboard/src/pages/ConfigEditor.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import { AddComponent } from "@/components/AddComponent"
|
||||
import { ComponentEditor } from "@/components/ComponentEditor"
|
||||
|
||||
interface ApplyResult {
|
||||
ok: boolean
|
||||
actions: string[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function ConfigEditorPage() {
|
||||
const qc = useQueryClient()
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null)
|
||||
|
||||
const { data: components, isLoading, refetch } = useQuery({
|
||||
queryKey: ["config-components"],
|
||||
queryFn: async () => {
|
||||
const list = await apiClient.get<{ id: string }[]>("/components")
|
||||
const details = await Promise.all(
|
||||
list.map((c) => apiClient.get<ComponentDetail>(`/components/${c.id}`))
|
||||
)
|
||||
return details
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (name: string, config: Record<string, unknown>) => {
|
||||
setMessage(null)
|
||||
setApplyResult(null)
|
||||
try {
|
||||
await apiClient.put(`/config/components/${name}`, { config })
|
||||
setMessage({ type: "ok", text: `Saved ${name}` })
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setMessage({ type: "error", text: msg })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiClient.delete(`/config/components/${name}`)
|
||||
setMessage({ type: "ok", text: `Removed ${name}` })
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setMessage({ type: "error", text: msg })
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
setApplying(true)
|
||||
setMessage(null)
|
||||
setApplyResult(null)
|
||||
try {
|
||||
const result = await apiClient.post<ApplyResult>("/config/apply")
|
||||
setApplyResult(result)
|
||||
setMessage({
|
||||
type: result.ok ? "ok" : "error",
|
||||
text: result.ok ? "Applied successfully" : "Applied with errors",
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
qc.invalidateQueries({ queryKey: ["status"] })
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setMessage({ type: "error", text: msg })
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1">
|
||||
<ArrowLeft size={16} /> Dashboard
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Configuration</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40"
|
||||
>
|
||||
{applying ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />}
|
||||
Apply All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-4 px-3 py-2 rounded text-sm ${
|
||||
message.type === "ok"
|
||||
? "bg-green-900/30 text-green-300 border border-green-800"
|
||||
: "bg-red-900/30 text-red-300 border border-red-800"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{message.type === "ok" && <Check size={14} />}
|
||||
{message.text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applyResult && (
|
||||
<div className="mb-4 bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 text-sm space-y-1">
|
||||
{applyResult.actions.map((a, i) => (
|
||||
<div key={i} className="text-green-400">{a}</div>
|
||||
))}
|
||||
{applyResult.errors.map((e, i) => (
|
||||
<div key={i} className="text-red-400">{e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading components...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{components?.map((comp) => (
|
||||
<ComponentEditor
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
<AddComponent
|
||||
existingNames={components?.map((c) => c.id) ?? []}
|
||||
onAdd={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
dashboard/src/pages/Dashboard.tsx
Normal file
46
dashboard/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { Settings } from "lucide-react"
|
||||
import { ComponentGrid } from "@/components/ComponentGrid"
|
||||
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
|
||||
|
||||
export function Dashboard() {
|
||||
useEventStream()
|
||||
const { data: components, isLoading } = useComponents()
|
||||
const { data: statusResp } = useStatus()
|
||||
const { data: gateway } = useGateway()
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Castle</h1>
|
||||
<p className="text-[var(--muted)] mt-1">
|
||||
Personal software platform
|
||||
{gateway && (
|
||||
<span className="ml-2 text-sm">
|
||||
· {gateway.component_count} components · port {gateway.port}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/config"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-[var(--border)] hover:bg-gray-600 text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<Settings size={14} /> Config
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading components...</p>
|
||||
) : components ? (
|
||||
<ComponentGrid
|
||||
components={components}
|
||||
statuses={statusResp?.statuses ?? []}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-red-400">Failed to load components</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
dashboard/src/router/routes.tsx
Normal file
19
dashboard/src/router/routes.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import { Dashboard } from "@/pages/Dashboard"
|
||||
import { ComponentDetailPage } from "@/pages/ComponentDetail"
|
||||
import { ConfigEditorPage } from "@/pages/ConfigEditor"
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Dashboard />,
|
||||
},
|
||||
{
|
||||
path: "/config",
|
||||
element: <ConfigEditorPage />,
|
||||
},
|
||||
{
|
||||
path: "/:name",
|
||||
element: <ComponentDetailPage />,
|
||||
},
|
||||
])
|
||||
64
dashboard/src/services/api/client.ts
Normal file
64
dashboard/src/services/api/client.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl = BASE_URL) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`)
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, await resp.text())
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: body ? { "Content-Type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, await resp.text())
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "PUT",
|
||||
headers: body ? { "Content-Type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, await resp.text())
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`, { method: "DELETE" })
|
||||
if (!resp.ok) {
|
||||
throw new ApiError(resp.status, await resp.text())
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
streamUrl(path: string): string {
|
||||
return `${this.baseUrl}${path}`
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
export { ApiError }
|
||||
76
dashboard/src/services/api/hooks.ts
Normal file
76
dashboard/src/services/api/hooks.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from "react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { apiClient } from "./client"
|
||||
import type {
|
||||
ComponentSummary,
|
||||
ComponentDetail,
|
||||
StatusResponse,
|
||||
GatewayInfo,
|
||||
ServiceActionResponse,
|
||||
SSEHealthEvent,
|
||||
} from "@/types"
|
||||
|
||||
export function useComponents() {
|
||||
return useQuery({
|
||||
queryKey: ["components"],
|
||||
queryFn: () => apiClient.get<ComponentSummary[]>("/components"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useComponent(name: string) {
|
||||
return useQuery({
|
||||
queryKey: ["components", name],
|
||||
queryFn: () => apiClient.get<ComponentDetail>(`/components/${name}`),
|
||||
enabled: !!name,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => apiClient.get<StatusResponse>("/status"),
|
||||
// SSE provides live updates; this is the fallback poll
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGateway() {
|
||||
return useQuery({
|
||||
queryKey: ["gateway"],
|
||||
queryFn: () => apiClient.get<GatewayInfo>("/gateway"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useServiceAction() {
|
||||
return useMutation({
|
||||
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
||||
apiClient.post<ServiceActionResponse>(`/services/${name}/${action}`),
|
||||
// SSE health event handles the UI update; no need to refetch here
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
const url = apiClient.streamUrl("/stream")
|
||||
const es = new EventSource(url)
|
||||
|
||||
es.addEventListener("health", (e) => {
|
||||
const data: SSEHealthEvent = JSON.parse(e.data)
|
||||
qc.setQueryData<StatusResponse>(["status"], { statuses: data.statuses })
|
||||
})
|
||||
|
||||
es.addEventListener("service-action", () => {
|
||||
// Health event already pushes correct status; just refetch components
|
||||
// in case the action changed what's available
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
// EventSource auto-reconnects; nothing to do
|
||||
}
|
||||
|
||||
return () => es.close()
|
||||
}, [qc])
|
||||
}
|
||||
57
dashboard/src/types/index.ts
Normal file
57
dashboard/src/types/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface ComponentSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
roles: string[]
|
||||
runner: string | null
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
proxy_path: string | null
|
||||
managed: boolean
|
||||
}
|
||||
|
||||
export interface ComponentDetail extends ComponentSummary {
|
||||
manifest: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
id: string
|
||||
status: "up" | "down" | "unknown"
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
port: number
|
||||
component_count: number
|
||||
service_count: number
|
||||
managed_count: number
|
||||
}
|
||||
|
||||
export interface ServiceActionResponse {
|
||||
component: string
|
||||
action: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface SSEHealthEvent {
|
||||
statuses: HealthStatus[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface SSEServiceActionEvent {
|
||||
action: string
|
||||
component: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
command: string
|
||||
description: string
|
||||
category: string
|
||||
version: string
|
||||
system_dependencies: string[]
|
||||
script: string
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
32
dashboard/tsconfig.app.json
Normal file
32
dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
dashboard/tsconfig.node.json
Normal file
26
dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
dashboard/vite.config.ts
Normal file
21
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:9020",
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user