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:
64
app/src/services/api/client.ts
Normal file
64
app/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 }
|
||||
149
app/src/services/api/hooks.ts
Normal file
149
app/src/services/api/hooks.ts
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user