diff --git a/AGENTS.md b/AGENTS.md index 329adee..b7b8d6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ READ ~/Code/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if file missing). Anton owns this project. Say hi when you start! -Any changes made should update the Unreleased section in CHANGELOG.md. +Only user-facing changes should update the Unreleased section in CHANGELOG.md. +Internal notes, planning docs, repo instructions, and similar housekeeping changes do not need changelog entries. If a changelog entry is later fixed by a follow-up change, merge the follow-up into the original entry instead of adding a new bullet. Any code or docs change should be followed immediately by a fresh build so local testing uses current artifacts. Keep the local `devservers` binary in `$PATH` and any `pnpm link` usage pointed at the latest repo build output so CLI testing always exercises the newest changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65358e8..02ddd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +### Changed +- Success notifications in the UI now dismiss automatically after five seconds, while actionable error notifications remain until dismissed manually. +- Refreshed the compact service dashboard with `@24letters/ui`, accessible forms and confirmations, responsive navigation, and clearer service actions and log connection states. +- CLI mutations verify daemon identity and canonical config paths. HTTP failures no longer trigger implicit daemon startup or restart, and requests have bounded deadlines and validated responses. +- Catalog reads no longer unregister missing projects or stop their services. Project removal is explicit, and invalid compose reloads retain the last valid service catalog. + +### Fixed +- Serialized config updates, port reservations, and lifecycle cascades to prevent lost updates, duplicate allocations, and overlapping start/stop operations. Daemons acquire storage ownership locks to prevent competing writers. +- Project registration validates before saving, and project removal only targets its own services. Interrupted service startup cleans up partially initialized panes. +- Log streams reject unknown services, bound polling and buffering, and stop work on disconnect. Stale port detection cannot update a replacement service, and daemon shutdown cancels its own work while preserving service panes. + ## 0.6.0 - 2026-07-15 ### Added diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..f306a18 --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,15 @@ +# Ideas + +## Fully Managed Environment Variable Onboarding + +When a project is fully all-in on `devservers`, onboarding can become fully guided. +When the user first tries to start the project, `devservers` can ask for any missing +`.env` variables or secrets in a wizard-style flow. + +In the command line, this could use a prompt style similar to `create-t3-app`, +asking the user questions about the required environment variables. In the UI, the +experience can be richer: the app can ask for the variables, store them, and keep +them managed from then on. + +The user then never has to worry about the contents of `.env.local`, `.env`, or +similar files. Environment variables are always fully managed by `devservers`. diff --git a/apps/ui/README.md b/apps/ui/README.md index d2e7761..99cb51b 100644 --- a/apps/ui/README.md +++ b/apps/ui/README.md @@ -1,73 +1,32 @@ -# React + TypeScript + Vite +# Devservers UI -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React dashboard for the Devservers daemon. Built with Vite, Tailwind CSS v4, and the +[`@24letters/ui`](https://github.com/atimmer/ui) component library (Base UI primitives, +Phosphor icons, Inter and Newsreader fonts). -Currently, two official plugins are available: +## Structure -- [@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 +- `src/App.tsx` composes the shell, dialogs, and confirmation flow. +- `src/hooks/` owns data flow: `use-services` polls the daemon with cancellation, + `use-selection` persists the main-pane target, `use-service-actions` runs lifecycle + actions with cascade confirmation, and `use-log-stream` keeps the log websocket alive + with reconnects. +- `src/components/` holds presentational pieces; `components/dialogs/` holds the add, + edit, project, and compose-definition dialogs. +- `src/lib/status.ts` maps service statuses to the `--status-*` tokens defined in + `src/index.css`. `src/lib/notify.ts` wraps Sonner toasts: successes dismiss after five + seconds, errors stay until dismissed. +- `src/api.ts`, `src/dashboard.ts`, and `src/logs.ts` are transport and pure helpers. -## React Compiler +## Development -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). +Do not run `pnpm dev` directly; the dev server is managed by the `devservers` CLI. +Point the UI at a different daemon or mock with `VITE_DAEMON_URL`. -## Expanding the ESLint configuration +Keyboard: `/` focuses the service filter, `Cmd+B` toggles the sidebar. -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... - }, - }, -]) +```sh +pnpm typecheck +pnpm lint +pnpm build ``` diff --git a/apps/ui/index.html b/apps/ui/index.html index 592716b..a13b507 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -1,10 +1,11 @@ - + - + - ui + + Devservers
diff --git a/apps/ui/package.json b/apps/ui/package.json index 9dcf640..a40f0d2 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -13,26 +13,26 @@ }, "dependencies": { "@24letters/devservers-shared": "workspace:*", - "@radix-ui/react-dialog": "^1.1.15", + "@24letters/ui": "0.1.1", + "@phosphor-icons/react": "2.1.10", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "sonner": "2.0.7" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.3.3", "@types/node": "^24.10.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.1", - "autoprefixer": "^10.4.23", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.3.3", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/apps/ui/public/favicon.svg b/apps/ui/public/favicon.svg new file mode 100644 index 0000000..1ab3c2a --- /dev/null +++ b/apps/ui/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ui/public/vite.svg b/apps/ui/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/apps/ui/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index f2c4512..fc0a664 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -1,328 +1,205 @@ -import { lazy, startTransition, Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { Button, Card, SidebarInset, SidebarProvider, Skeleton } from "@24letters/ui"; import { addProject, addService, deleteProject, deleteService, - getProjects, - getServiceConfigDefinition, - getServices, restartService, - startService, - stopService, updateService, type RegisteredProject, - type ServiceConfigDefinition, type ServiceInfo, type ServiceInput, } from "./api"; -import { ConfigDialog, ProjectDialog, ServiceDialog } from "./components/Dialogs"; -import { ErrorBoundary } from "./components/ErrorBoundary"; -import { Sidebar } from "./components/Sidebar"; -import { ServiceCard, type ServiceAction } from "./components/ServiceCard"; +import { getWorkingDirectory, groupServices, isServiceActive, summarizeAction } from "./dashboard"; +import { useConfirm } from "./components/confirm-dialog"; +import { ConfigDialog } from "./components/dialogs/config-dialog"; +import { ProjectDialog } from "./components/dialogs/project-dialog"; +import { ServiceDialog } from "./components/dialogs/service-dialog"; +import { EmptyState } from "./components/empty-state"; +import { ErrorBoundary } from "./components/error-boundary"; +import { ServiceCard } from "./components/service-card"; +import { ServiceHeader } from "./components/service-header"; +import { ServiceSidebar } from "./components/service-sidebar"; +import { useSelection } from "./hooks/use-selection"; +import { useServiceActions } from "./hooks/use-service-actions"; +import { useServices } from "./hooks/use-services"; import { - getStopImpact, - getWorkingDirectory, - groupServices, - isServiceActive, - summarizeAction, - type MainSelection, -} from "./dashboard"; + ArrowClockwiseIcon, + PlugsConnectedIcon, + PlusIcon, + StackIcon, + TerminalWindowIcon, +} from "./lib/icons"; +import { errorMessage, notifyError, notifySuccess } from "./lib/notify"; -type Notice = { id: number; kind: "success" | "error"; message: string }; -const selectionKey = "devservers.selection"; const LogsPanel = lazy(() => - import("./components/LogsPanel").then((module) => ({ default: module.LogsPanel })), + import("./components/logs-panel").then((module) => ({ default: module.LogsPanel })), ); -const readSelection = (): MainSelection | null => { - try { - const value = JSON.parse(localStorage.getItem(selectionKey) ?? "null") as unknown; - if (typeof value !== "object" || value === null || !("type" in value)) return null; - if (value.type === "service" && "serviceName" in value && typeof value.serviceName === "string") - return { type: "service", serviceName: value.serviceName }; - if (value.type === "working-copy" && "groupKey" in value && typeof value.groupKey === "string") - return { type: "working-copy", groupKey: value.groupKey }; - } catch { - return null; - } - return null; -}; +const needsAttention = (service: ServiceInfo) => + service.status === "error" || (service.status === "exited" && (service.exitCode ?? 0) !== 0); + +const LoadingCards = () => ( +
+ + + + + + +
+); function AppContent() { - const [services, setServices] = useState([]); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [selection, setSelection] = useState(readSelection); + const { confirm, dialog: confirmDialog } = useConfirm(); + const { services, projects, loading, loaded, error, refresh } = useServices(); + const groups = useMemo(() => groupServices(services), [services]); + const { selection, select, selectedService, selectedGroup, serviceMap } = useSelection( + services, + groups, + ); + const { pending, runAction } = useServiceActions({ services, serviceMap, refresh, confirm }); const [query, setQuery] = useState(""); - const [pending, setPending] = useState>({}); - const [notices, setNotices] = useState([]); - const [editingService, setEditingService] = useState(null); - const [serviceDialogOpen, setServiceDialogOpen] = useState(false); + const [serviceDialog, setServiceDialog] = useState<{ open: boolean; service: ServiceInfo | null }>( + { open: false, service: null }, + ); const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [configService, setConfigService] = useState(null); - const [configDefinition, setConfigDefinition] = useState(null); - const [configLoading, setConfigLoading] = useState(false); - - const addNotice = useCallback((kind: Notice["kind"], message: string, replacePrefix?: string) => { - setNotices((current) => [ - ...current.filter((notice) => !replacePrefix || !notice.message.startsWith(replacePrefix)), - { id: Date.now() + Math.random(), kind, message }, - ]); - }, []); - - const refresh = useCallback( - async (initial = false) => { - try { - const [nextServices, nextProjects] = await Promise.all([getServices(), getProjects()]); - startTransition(() => { - setServices(nextServices); - setProjects(nextProjects); - setLoading(false); - setNotices((current) => - current.filter((notice) => !notice.message.startsWith("Refresh failed:")), - ); - }); - } catch (cause) { - if (initial) setLoading(false); - addNotice( - "error", - `Refresh failed: ${cause instanceof Error ? cause.message : String(cause)}`, - "Refresh failed:", - ); - } - }, - [addNotice], - ); - - useEffect(() => { - void refresh(true); - const timer = window.setInterval(() => void refresh(), 4000); - return () => window.clearInterval(timer); - }, [refresh]); - const groups = useMemo(() => groupServices(services), [services]); - const serviceMap = useMemo( - () => new Map(services.map((service) => [service.name, service])), + const visibleServices = selectedService ? [selectedService] : (selectedGroup?.services ?? []); + const counts = useMemo( + () => ({ + running: services.filter((service) => isServiceActive(service.status)).length, + attention: services.filter(needsAttention).length, + }), [services], ); - const groupMap = useMemo(() => new Map(groups.map((group) => [group.key, group])), [groups]); - const selectedService = - selection?.type === "service" ? (serviceMap.get(selection.serviceName) ?? null) : null; - const selectedGroup = - selection?.type === "working-copy" ? (groupMap.get(selection.groupKey) ?? null) : null; - const visibleServices = selectedService ? [selectedService] : (selectedGroup?.services ?? []); - const select = useCallback((next: MainSelection) => { - setSelection(next); - localStorage.setItem(selectionKey, JSON.stringify(next)); - }, []); + // Only compose services belong to a registered project. + const projectFor = (service: ServiceInfo) => + service.source === "compose" + ? (projects.find((project) => project.name === service.projectName) ?? null) + : null; - useEffect(() => { - if (services.length === 0) { - setSelection(null); - return; - } - if (selection?.type === "service" && serviceMap.has(selection.serviceName)) return; - if (selection?.type === "working-copy" && groupMap.has(selection.groupKey)) return; - const running = services.find((service) => isServiceActive(service.status)); - select( - running - ? { type: "service", serviceName: running.name } - : { type: "working-copy", groupKey: groups[0].key }, - ); - }, [groupMap, groups, select, selection, serviceMap, services]); - - const projectFor = useCallback( - (service: ServiceInfo) => - projects.find( - (project) => - project.name === service.projectName || - service.cwd === project.path || - service.cwd.startsWith(`${project.path}/`), - ) ?? null, - [projects], - ); - - const runAction = useCallback( - async (action: ServiceAction, service: ServiceInfo) => { - if (pending[service.name]) return; - if (action === "stop") { - const impact = getStopImpact(services, service.name).filter( - (name) => serviceMap.get(name)?.status !== "stopped", - ); - if ( - impact.length > 1 && - !window.confirm( - `Stopping ${service.name} also stops dependents: ${impact.slice(1).join(", ")}. Continue?`, - ) - ) - return; - } - setPending((current) => ({ ...current, [service.name]: action })); - try { - const result = - action === "start" - ? await startService(service.name) - : action === "stop" - ? await stopService(service.name) - : await restartService(service.name); - addNotice("success", summarizeAction(result)); - await refresh(); - } catch (cause) { - addNotice( - "error", - `${action} ${service.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } finally { - setPending((current) => { - const next = { ...current }; - delete next[service.name]; - return next; - }); - } - }, - [addNotice, pending, refresh, serviceMap, services], - ); + const closeServiceDialog = () => setServiceDialog({ open: false, service: null }); const saveService = async (input: ServiceInput, restart: boolean) => { - if (editingService) { - await updateService(editingService.name, input); - if (restart) addNotice("success", summarizeAction(await restartService(editingService.name))); - else addNotice("success", `Saved ${editingService.name}.`); + const editing = serviceDialog.service; + if (editing) { + await updateService(editing.name, input); + if (restart) notifySuccess(summarizeAction(await restartService(editing.name))); + else notifySuccess(`Saved ${editing.name}.`); } else { await addService(input); - addNotice("success", `Added ${input.name}.`); + notifySuccess(`Added ${input.name}.`); } - setServiceDialogOpen(false); - setEditingService(null); + closeServiceDialog(); await refresh(); }; const removeService = async (service: ServiceInfo) => { + const ok = await confirm({ + title: `Delete ${service.name}?`, + description: "A running process is stopped first. The service is removed from the config.", + confirmLabel: "Delete", + destructive: true, + }); + if (!ok) return false; const result = await deleteService(service.name); - addNotice("success", `${summarizeAction(result)} Its managed process was stopped.`); - setServiceDialogOpen(false); - setEditingService(null); + notifySuccess(`${summarizeAction(result)} Its managed process was stopped.`); + closeServiceDialog(); await refresh(); + return true; }; - const openConfig = async (service: ServiceInfo) => { - setConfigService(service); - setConfigDefinition(null); - setConfigLoading(true); + const unregisterProject = async (project: RegisteredProject) => { + const ok = await confirm({ + title: `Unregister ${project.name}?`, + description: "Its compose services disappear from the manager. Files are not touched.", + confirmLabel: "Unregister", + destructive: true, + }); + if (!ok) return; try { - setConfigDefinition(await getServiceConfigDefinition(service.name)); + await deleteProject(project.name); + notifySuccess(`Unregistered ${project.name}.`); + await refresh(); } catch (cause) { - addNotice( - "error", - `Config failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } finally { - setConfigLoading(false); + notifyError(`Unregister ${project.name} failed: ${errorMessage(cause)}`); } }; - const counts = useMemo( - () => ({ - running: services.filter((service) => isServiceActive(service.status)).length, - attention: services.filter( - (service) => service.status === "error" || service.status === "exited", - ).length, - }), - [services], - ); const title = selectedService?.name ?? selectedGroup?.title ?? "Dev servers"; - const subtitle = selectedService - ? getWorkingDirectory(selectedService) - : (selectedGroup?.root ?? "Choose a service from the sidebar"); + const subtitle = selectedService ? getWorkingDirectory(selectedService) : selectedGroup?.root; return ( -
-
-
- + + + select({ type: "working-copy", groupKey })} + onAddService={() => setServiceDialog({ open: true, service: null })} + onAddProject={() => setProjectDialogOpen(true)} /> -
-
-
-

{title}

- - {subtitle} - -
-
- - {counts.running} running - - {counts.attention ? ( - - {counts.attention} attention - - ) : null} - - -
-
- {notices.length ? ( -
- {notices.map((notice) => ( -
- {notice.message} - -
- ))} -
- ) : null} +
{loading ? ( -
- Loading services… -
+ + ) : !loaded && error ? ( + + + ) : services.length === 0 ? ( -
- No services registered yet. -
+ + + ) : visibleServices.length === 0 ? ( -
- Select a service or working copy. -
+ ) : ( <> -
+
+

+ {title} +

+ {subtitle ? ( + + {subtitle} + + ) : null} +
+
{visibleServices.map((service) => ( void runAction(action, target)} - onEdit={(target) => { - setEditingService(target); - setServiceDialogOpen(true); - }} - onConfig={(target) => void openConfig(target)} - onUnregister={(name) => { - if (window.confirm(`Unregister project ${name}?`)) - void deleteProject(name) - .then(() => refresh()) - .catch((cause: unknown) => - addNotice( - "error", - cause instanceof Error ? cause.message : String(cause), - ), - ); - }} + onEdit={(target) => setServiceDialog({ open: true, service: target })} + onConfig={setConfigService} + onUnregister={(project) => void unregisterProject(project)} /> ))}
{selectedService ? ( - - Loading terminal… -
- } - > + }> ) : null} )} -
-
- {serviceDialogOpen ? ( +
+ + {serviceDialog.open ? ( service.name)} - onClose={() => { - setServiceDialogOpen(false); - setEditingService(null); - }} + onClose={closeServiceDialog} onSave={saveService} onDelete={removeService} /> @@ -383,24 +238,17 @@ function AppContent() { onClose={() => setProjectDialogOpen(false)} onSave={async (project) => { await addProject(project); - addNotice("success", `Registered ${project.name}.`); + notifySuccess(`Added project ${project.name}.`); setProjectDialogOpen(false); await refresh(); }} /> ) : null} {configService ? ( - { - setConfigService(null); - setConfigDefinition(null); - }} - /> + setConfigService(null)} /> ) : null} -
+ {confirmDialog} + ); } diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts index b51d260..c16b70a 100644 --- a/apps/ui/src/api.ts +++ b/apps/ui/src/api.ts @@ -1,3 +1,10 @@ +import { + servicesResponseSchema, + projectsResponseSchema, + serviceActionResultSchema, + serviceConfigDefinitionSchema, + successResponseSchema, +} from "@24letters/devservers-shared"; import type { DevServerService, RegisteredProject, @@ -127,90 +134,60 @@ const readErrorMessage = async (response: Response): Promise => { } }; -export const getServices = async (): Promise => { - const response = await fetch(`${API_BASE}/services`); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - const payload = (await response.json()) as { services: ServiceInfo[] }; - return payload.services; -}; - -export const addService = async (service: ServiceInput) => { - const response = await fetch(`${API_BASE}/services`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(service), +const request = async ( + pathname: string, + schema: { parse: (value: unknown) => T }, + init?: RequestInit, +): Promise => { + const deadline = AbortSignal.timeout(15_000); + const response = await fetch(`${API_BASE.replace(/\/$/, "")}${pathname}`, { + ...init, + signal: init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline, }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); + if (!response.ok) throw new Error(await readErrorMessage(response)); + try { + return schema.parse(await response.json()); + } catch (cause) { + if (cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError")) + throw cause; + init?.signal?.throwIfAborted(); + deadline.throwIfAborted(); + throw new Error("Unexpected response from daemon", { cause }); } }; - -export const updateService = async (name: string, service: ServiceInput) => { - const response = await fetch(`${API_BASE}/services/${name}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(service), +const jsonBody = (method: string, body: unknown): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); +export const getServices = async (signal?: AbortSignal): Promise => + (await request("/services", servicesResponseSchema, { signal })).services; +export const addService = (service: ServiceInput) => + request("/services", successResponseSchema, jsonBody("POST", service)); +export const updateService = (name: string, service: ServiceInput) => + request(`/services/${encodeURIComponent(name)}`, successResponseSchema, jsonBody("PUT", service)); +export const deleteService = (name: string): Promise => + request(`/services/${encodeURIComponent(name)}`, serviceActionResultSchema, { method: "DELETE" }); +export const getProjects = async (signal?: AbortSignal): Promise => + (await request("/projects", projectsResponseSchema, { signal })).projects; +export const addProject = (project: RegisteredProject) => + request("/projects", successResponseSchema, jsonBody("POST", project)); +export const deleteProject = (name: string) => + request(`/projects/${encodeURIComponent(name)}`, successResponseSchema, { method: "DELETE" }); +export const getServiceConfigDefinition = ( + name: string, + signal?: AbortSignal, +): Promise => + request(`/services/${encodeURIComponent(name)}/config`, serviceConfigDefinitionSchema, { + signal, }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } -}; - -export const deleteService = async (name: string) => { - const response = await fetch(`${API_BASE}/services/${name}`, { method: "DELETE" }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - return (await response.json()) as ServiceActionResult; -}; - -export const getProjects = async (): Promise => { - const response = await fetch(`${API_BASE}/projects`); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - const payload = (await response.json()) as { projects: RegisteredProject[] }; - return payload.projects; -}; - -export const addProject = async (project: RegisteredProject) => { - const response = await fetch(`${API_BASE}/projects`, { +const postAction = ( + name: string, + action: "start" | "stop" | "restart", +): Promise => + request(`/services/${encodeURIComponent(name)}/${action}`, serviceActionResultSchema, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(project), }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } -}; - -export const deleteProject = async (name: string) => { - const response = await fetch(`${API_BASE}/projects/${name}`, { method: "DELETE" }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } -}; - -export const getServiceConfigDefinition = async ( - name: string, -): Promise => { - const response = await fetch(`${API_BASE}/services/${name}/config`); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - return (await response.json()) as ServiceConfigDefinition; -}; - -const postAction = async (name: string, action: "start" | "stop" | "restart") => { - const response = await fetch(`${API_BASE}/services/${name}/${action}`, { method: "POST" }); - if (!response.ok) { - throw new Error(await readErrorMessage(response)); - } - return (await response.json()) as ServiceActionResult; -}; - export const startService = (name: string) => postAction(name, "start"); export const stopService = (name: string) => postAction(name, "stop"); export const restartService = (name: string) => postAction(name, "restart"); @@ -220,5 +197,7 @@ export const createLogsSocket = (name: string, lines = 200, ansi = false) => { if (ansi) { params.set("ansi", "1"); } - return new WebSocket(`${WS_BASE}/services/${name}/logs?${params.toString()}`); + return new WebSocket( + `${WS_BASE.replace(/\/$/, "")}/services/${encodeURIComponent(name)}/logs?${params.toString()}`, + ); }; diff --git a/apps/ui/src/assets/react.svg b/apps/ui/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/apps/ui/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/ui/src/components/Dialogs.tsx b/apps/ui/src/components/Dialogs.tsx deleted file mode 100644 index b4816fd..0000000 --- a/apps/ui/src/components/Dialogs.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import * as Dialog from "@radix-ui/react-dialog"; -import { useMemo, useState } from "react"; -import type { PortMode, ServiceConfigDefinition, ServiceInfo, ServiceInput } from "../api"; -import { formatEnv, parseEnv } from "../dashboard"; - -const overlay = "fixed inset-0 z-50 bg-black/70"; -const content = - "fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 outline-none sm:items-center"; -const panel = "w-full max-w-2xl border border-white/10 bg-[#0c1118] p-5 shadow-2xl"; -const input = - "w-full border border-white/10 bg-white/5 px-3 py-2 font-mono text-sm text-white outline-none focus:border-cyan-300/50"; -const label = "grid gap-1.5 text-xs text-slate-400"; - -const Header = ({ eyebrow, title }: { eyebrow: string; title: string }) => ( -
-
- {eyebrow} - {title} -
- Close -
-); - -type ServiceFormState = { - name: string; - cwd: string; - command: string; - port: string; - portMode: PortMode; - env: string; - dependsOn: string[]; -}; -const emptyForm: ServiceFormState = { - name: "", - cwd: "", - command: "", - port: "", - portMode: "static", - env: "", - dependsOn: [], -}; - -export function ServiceDialog({ - open, - service, - serviceNames, - onClose, - onSave, - onDelete, -}: { - open: boolean; - service: ServiceInfo | null; - serviceNames: string[]; - onClose: () => void; - onSave: (input: ServiceInput, restart: boolean) => Promise; - onDelete: (service: ServiceInfo) => Promise; -}) { - const [form, setForm] = useState(() => - service - ? { - name: service.name, - cwd: service.cwd, - command: service.command, - port: service.port ? String(service.port) : "", - portMode: service.portMode ?? "static", - env: formatEnv(service.env), - dependsOn: service.dependsOn ?? [], - } - : emptyForm, - ); - const [error, setError] = useState(null); - const [saving, setSaving] = useState<"save" | "restart" | "delete" | null>(null); - const dependencies = useMemo( - () => serviceNames.filter((name) => name !== form.name.trim()), - [serviceNames, form.name], - ); - const submit = async (restart: boolean) => { - setError(null); - setSaving(restart ? "restart" : "save"); - try { - await onSave( - { - name: form.name.trim(), - cwd: form.cwd.trim(), - command: form.command.trim(), - port: form.port ? Number(form.port) : undefined, - portMode: form.portMode, - env: parseEnv(form.env), - dependsOn: form.dependsOn.length ? form.dependsOn : undefined, - }, - restart, - ); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - setSaving(null); - } - }; - return ( - { - if (!next && !saving) onClose(); - }} - > - - - -
-
- {error ? ( -

- {error} -

- ) : null} -
- - - -
- - {form.portMode === "static" ? ( - - ) : null} -
-
- Dependencies -
- {dependencies.length ? ( - dependencies.map((name) => ( - - )) - ) : ( -

No other services.

- )} -
-
-