feat: add ComponentGrid and ComponentTable components for displaying components in grid and table formats

feat: implement HealthBadge and RoleBadge components for displaying health status and roles

feat: create LogViewer component for streaming logs of services

feat: develop SecretsEditor for managing secrets with CRUD operations

feat: introduce ToolCard component for displaying tool information

style: add global CSS variables and styles for consistent theming

feat: set up API client for handling requests to the backend

feat: implement hooks for fetching components, statuses, and tools

feat: create routes for dashboard, component details, and tools

feat: add service management endpoints for starting, stopping, and restarting services

feat: implement event bus for handling real-time updates via SSE

feat: create health check and logs endpoints for monitoring services

feat: add tests for health and tools endpoints to ensure functionality

chore: update project configuration files and dependencies for better development experience
This commit is contained in:
2026-02-21 01:23:37 -08:00
parent f99c2dad86
commit 930bc601b7
63 changed files with 154 additions and 157 deletions

View 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 }

View File

@@ -0,0 +1,149 @@
import { useEffect } from "react"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import { apiClient } from "./client"
import type {
ComponentSummary,
ComponentDetail,
StatusResponse,
GatewayInfo,
ServiceActionResponse,
SSEHealthEvent,
ToolCategory,
ToolDetail,
} 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 useCaddyfile(enabled = true) {
return useQuery({
queryKey: ["gateway", "caddyfile"],
queryFn: () => apiClient.get<{ content: string }>("/gateway/caddyfile"),
enabled,
})
}
async function waitForApi(attempts = 20, interval = 1000): Promise<void> {
for (let i = 0; i < attempts; i++) {
try {
await apiClient.get<{ status: string }>("/health")
return
} catch {
await new Promise((r) => setTimeout(r, interval))
}
}
}
export function useSystemdUnit(name: string, enabled = true) {
return useQuery({
queryKey: ["services", name, "unit"],
queryFn: () => apiClient.get<{ service: string; timer: string | null }>(`/services/${name}/unit`),
enabled: enabled && !!name,
})
}
export function useServiceAction() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, action }: { name: string; action: string }) => {
try {
return await apiClient.post<ServiceActionResponse>(`/services/${name}/${action}`)
} catch (err) {
// Network error from self-restart killing the connection — expected
if (err instanceof TypeError) {
return { component: name, action, status: "accepted" } as ServiceActionResponse
}
throw err
}
},
onSuccess: async (data) => {
if (data.status === "accepted") {
// API is restarting itself — poll until it's back, then refresh everything
await waitForApi()
qc.invalidateQueries()
}
},
})
}
export function useToolAction() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) =>
apiClient.post<{ component: string; action: string; status: string }>(
`/tools/${name}/${action}`,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["components"] })
},
})
}
export function useTools() {
return useQuery({
queryKey: ["tools"],
queryFn: () => apiClient.get<ToolCategory[]>("/tools"),
})
}
export function useToolDetail(name: string) {
return useQuery({
queryKey: ["tools", name],
queryFn: () => apiClient.get<ToolDetail>(`/tools/${name}`),
enabled: !!name,
})
}
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])
}