diff --git a/packages/api/README.md b/packages/api/README.md index 232e3141..e562564e 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -36,14 +36,6 @@ Diagnostic classification + remediation messages live in `packages/app/src/docker-git/controller-docker-diagnostics.ts` and are covered by `packages/app/tests/docker-git/controller-docker-diagnostics.test.ts`. -## UI wrapper - -After API startup open: - -- `http://localhost:3334/` - -This page is a built-in UI shell for manual API checks without CLI. - ## Run (local) ```bash diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 1a6a3a51..12b9c62e 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -42,7 +42,6 @@ import { UpProjectRequestSchema } from "./api/schema.js" import type { UpProjectRequestInput } from "./api/schema.js" -import { uiHtml, uiScript, uiStyles } from "./ui.js" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import { resolveWorkspaceRoot } from "@effect-template/lib/shell/workspace-root" import { @@ -767,16 +766,7 @@ const projectProxyResponse = Effect.gen(function*(_) { }) export const makeRouter = () => { - const withUi = HttpRouter.empty.pipe( - HttpRouter.get("/", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - console.log("GET / request:", request.url, "headers:", request.headers) - return yield* _(textResponse(uiHtml, "text/html; charset=utf-8", 200)) - }).pipe(Effect.catchAll(errorResponse)) - ), - HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), - HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), + const withCoreRoutes = HttpRouter.empty.pipe( HttpRouter.get( "/health", Effect.gen(function*(_) { @@ -810,7 +800,7 @@ export const makeRouter = () => { ) ) - const withAuth = withUi.pipe( + const withAuth = withCoreRoutes.pipe( HttpRouter.get( "/auth/github/status", Effect.gen(function*(_) { diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts deleted file mode 100644 index 4e85a4b3..00000000 --- a/packages/api/src/ui.ts +++ /dev/null @@ -1,945 +0,0 @@ -export const uiStyles = `@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); - -:root { - --bg: #f6f7f9; - --surface: rgba(255, 255, 255, 0.88); - --surface-strong: #ffffff; - --text: #12222f; - --muted: #516574; - --line: rgba(18, 34, 47, 0.14); - --accent: #0466c8; - --accent-2: #0096c7; - --danger: #b42318; - --ok: #117a65; - --shadow: 0 16px 42px rgba(7, 31, 51, 0.12); - --radius: 16px; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - font-family: "Space Grotesk", "Segoe UI", sans-serif; - color: var(--text); - background: - radial-gradient(circle at 10% -10%, rgba(4, 102, 200, 0.18), transparent 45%), - radial-gradient(circle at 90% 0%, rgba(0, 150, 199, 0.16), transparent 40%), - linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%); -} - -body { - min-height: 100vh; -} - -.app-shell { - width: min(1260px, 100% - 2rem); - margin: 1rem auto 2rem; -} - -.hero { - padding: 1rem 0; -} - -.hero h1 { - margin: 0; - font-size: clamp(1.4rem, 2vw + 0.6rem, 2.2rem); - letter-spacing: 0.01em; -} - -.hero p { - margin: 0.35rem 0 0; - color: var(--muted); -} - -.toolbar { - display: flex; - gap: 0.6rem; - flex-wrap: wrap; - align-items: center; - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 0.8rem; -} - -.toolbar input { - flex: 1 1 260px; -} - -.grid { - display: grid; - grid-template-columns: minmax(300px, 0.95fr) minmax(340px, 1.2fr); - gap: 0.9rem; - margin-top: 0.9rem; -} - -.stack { - display: grid; - gap: 0.9rem; -} - -.panel { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow: hidden; -} - -.panel-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.6rem; - padding: 0.72rem 0.85rem; - border-bottom: 1px solid var(--line); - background: linear-gradient(135deg, rgba(4, 102, 200, 0.06), rgba(0, 150, 199, 0.04)); -} - -.panel-head h2, -.panel-head h3 { - margin: 0; - font-size: 1rem; -} - -.panel-body { - padding: 0.82rem; -} - -label { - font-size: 0.8rem; - color: var(--muted); - display: block; - margin-bottom: 0.2rem; -} - -input, -select, -textarea, -button { - font: inherit; -} - -input, -select, -textarea { - width: 100%; - border-radius: 10px; - border: 1px solid var(--line); - background: var(--surface-strong); - padding: 0.52rem 0.6rem; - color: var(--text); -} - -textarea, -pre, -code, -.output, -.events { - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; -} - -textarea { - min-height: 88px; - resize: vertical; -} - -.row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.55rem; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -button { - border: 0; - border-radius: 999px; - padding: 0.45rem 0.86rem; - font-weight: 600; - cursor: pointer; - color: #fff; - background: linear-gradient(135deg, var(--accent), var(--accent-2)); -} - -button[data-variant="ghost"] { - color: var(--text); - border: 1px solid var(--line); - background: #fff; -} - -button[data-variant="danger"] { - background: linear-gradient(135deg, #b42318, #da3f34); -} - -button[data-variant="ok"] { - background: linear-gradient(135deg, #0c8a5c, #0d9d68); -} - -button:disabled { - opacity: 0.62; - cursor: not-allowed; -} - -.project-list { - display: grid; - gap: 0.45rem; - max-height: 350px; - overflow: auto; -} - -.project-card { - border: 1px solid var(--line); - border-radius: 12px; - padding: 0.55rem; - background: #fff; -} - -.project-card.active { - border-color: rgba(4, 102, 200, 0.5); - box-shadow: 0 0 0 2px rgba(4, 102, 200, 0.12); -} - -.project-card .top { - display: flex; - justify-content: space-between; - gap: 0.4rem; - align-items: center; -} - -.badge { - border-radius: 999px; - padding: 0.1rem 0.5rem; - font-size: 0.75rem; - border: 1px solid var(--line); - background: #fff; -} - -.badge.running { - color: var(--ok); - border-color: rgba(17, 122, 101, 0.36); - background: rgba(17, 122, 101, 0.1); -} - -.badge.stopped { - color: #9a4d04; - border-color: rgba(154, 77, 4, 0.3); - background: rgba(154, 77, 4, 0.11); -} - -.kv { - display: grid; - grid-template-columns: 170px 1fr; - gap: 0.3rem 0.6rem; - font-size: 0.85rem; -} - -.kv .k { - color: var(--muted); -} - -pre { - margin: 0; - white-space: pre-wrap; - border: 1px solid var(--line); - border-radius: 10px; - background: #f9fcff; - padding: 0.68rem; - max-height: 240px; - overflow: auto; -} - -.events, -.output { - border: 1px solid var(--line); - border-radius: 10px; - background: #0f1a23; - color: #d8e7f5; - padding: 0.68rem; - min-height: 130px; - max-height: 260px; - overflow: auto; - white-space: pre-wrap; - font-size: 0.8rem; -} - -.agents { - display: grid; - gap: 0.6rem; -} - -.agent-item { - border: 1px solid var(--line); - border-radius: 10px; - padding: 0.56rem; - background: #fff; - display: grid; - gap: 0.45rem; -} - -.agent-item .top { - display: flex; - justify-content: space-between; - gap: 0.4rem; - align-items: center; -} - -.small { - font-size: 0.8rem; - color: var(--muted); -} - -.mono { - font-family: "IBM Plex Mono", ui-monospace, monospace; - word-break: break-word; -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 0.45rem; -} - -.checkbox-row input { - width: auto; -} - -@media (max-width: 980px) { - .grid { - grid-template-columns: 1fr; - } - - .row { - grid-template-columns: 1fr; - } - - .kv { - grid-template-columns: 1fr; - } -} -` - -export const uiHtml = ` - - - - - docker-git API Console - - - -
-
-

docker-git API Console

-

UI-обвязка для тестирования v1 API без CLI

-
- -
-
- - -
- - -
- -
-
-
-
-

Создать проект

-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - - - - - -
-
- -
-
-
- -
-
-

Проекты

- 0 -
-
-
-
-
-
- -
-
-
-

Проект

- not selected -
-
-
- -
- - - - - - -
- - -

-
-              
- - -
- -
-
-
- -
-
-

Агенты

- -
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
-
-
- -
-
-

Debug output

- -
-
-
-
-
-
- - - - -` - -export const uiScript = ` -(() => { - const state = { - baseUrl: '', - projectId: '', - project: null, - projects: [], - agents: [], - eventSource: null, - eventCursor: 0 - }; - - const byId = (id) => document.getElementById(id); - - const views = { - baseUrl: byId('base-url'), - projectsCount: byId('projects-count'), - projectsList: byId('projects-list'), - activeProjectId: byId('active-project-id'), - projectDetails: byId('project-details'), - projectOutput: byId('project-output'), - eventsLog: byId('events-log'), - debugOutput: byId('debug-output'), - agentProvider: byId('agent-provider'), - agentLabel: byId('agent-label'), - agentCommand: byId('agent-command'), - agentCwd: byId('agent-cwd'), - agentEnv: byId('agent-env'), - agentsList: byId('agents-list'), - createRepoUrl: byId('create-repo-url'), - createRepoRef: byId('create-repo-ref'), - createSshPort: byId('create-ssh-port'), - createNetworkMode: byId('create-network-mode'), - createCpu: byId('create-cpu'), - createRam: byId('create-ram'), - createUp: byId('create-up'), - createForce: byId('create-force'), - createForceEnv: byId('create-force-env') - }; - - const appendDebug = (label, payload) => { - const stamp = new Date().toISOString(); - const line = '[' + stamp + '] ' + label + '\\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); - views.debugOutput.textContent = (line + '\\n\\n' + views.debugOutput.textContent).slice(0, 24000); - }; - - const normalizeBase = (value) => { - const trimmed = String(value || '').trim(); - if (!trimmed) { - return window.location.origin; - } - return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; - }; - - const projectPath = (projectId, suffix) => '/projects/' + encodeURIComponent(projectId) + suffix; - - const request = async (path, init) => { - const base = normalizeBase(views.baseUrl.value); - state.baseUrl = base; - const url = base + path; - const response = await fetch(url, init || {}); - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch (_error) { - json = { raw: text }; - } - - if (!response.ok) { - appendDebug('HTTP ' + response.status + ' ' + path, json); - throw new Error((json && json.error && json.error.message) || ('HTTP ' + response.status)); - } - - appendDebug('HTTP ' + response.status + ' ' + path, json || text); - return json; - }; - - const setProjectOutput = (value) => { - views.projectOutput.textContent = value || ''; - }; - - const renderProjectDetails = () => { - views.activeProjectId.textContent = state.projectId || 'not selected'; - if (!state.project) { - views.projectDetails.innerHTML = '
Выберите проект слева
'; - return; - } - - const details = [ - ['displayName', state.project.displayName], - ['repo', state.project.repoUrl + ' @ ' + state.project.repoRef], - ['status', state.project.status + ' (' + state.project.statusLabel + ')'], - ['container', state.project.containerName], - ['service', state.project.serviceName], - ['ssh', state.project.sshCommand], - ['targetDir', state.project.targetDir] - ]; - - views.projectDetails.innerHTML = details.map(([k, v]) => '
' + k + '
' + String(v) + '
').join(''); - }; - - const renderProjects = () => { - views.projectsCount.textContent = String(state.projects.length); - if (state.projects.length === 0) { - views.projectsList.innerHTML = '
Проекты не найдены
'; - return; - } - - views.projectsList.innerHTML = state.projects.map((item) => { - const activeClass = item.id === state.projectId ? ' active' : ''; - const badgeClass = item.status === 'running' ? 'running' : (item.status === 'stopped' ? 'stopped' : ''); - return [ - '
', - '
', - '' + item.displayName + '', - '' + item.status + '', - '
', - '
' + item.repoRef + '
', - '
', - '
' - ].join(''); - }).join(''); - - views.projectsList.querySelectorAll('button[data-project-id]').forEach((button) => { - button.addEventListener('click', () => { - selectProject(button.getAttribute('data-project-id') || ''); - }); - }); - }; - - const loadProjects = async () => { - const payload = await request('/projects'); - state.projects = (payload && payload.projects) || []; - renderProjects(); - - if (!state.projectId && state.projects.length > 0) { - await selectProject(state.projects[0].id); - } - }; - - const loadProject = async () => { - if (!state.projectId) { - return; - } - const payload = await request(projectPath(state.projectId, '')); - state.project = payload.project; - renderProjectDetails(); - }; - - const selectProject = async (projectId) => { - if (!projectId) { - return; - } - state.projectId = projectId; - renderProjects(); - await loadProject(); - await loadAgents(); - }; - - const loadAgents = async () => { - if (!state.projectId) { - views.agentsList.innerHTML = '
Сначала выберите проект
'; - return; - } - - const payload = await request(projectPath(state.projectId, '/agents')); - state.agents = (payload && payload.sessions) || []; - renderAgents(); - }; - - const renderAgents = () => { - if (!state.projectId) { - views.agentsList.innerHTML = '
Сначала выберите проект
'; - return; - } - - if (state.agents.length === 0) { - views.agentsList.innerHTML = '
Агенты не запущены
'; - return; - } - - views.agentsList.innerHTML = state.agents.map((agent) => { - return [ - '
', - '
', - '' + agent.label + '', - '' + agent.status + '', - '
', - '
' + agent.id + '
', - '
' + agent.command + '
', - '
', - '', - '', - '', - '
', - '
' - ].join(''); - }).join(''); - - views.agentsList.querySelectorAll('button[data-action]').forEach((button) => { - button.addEventListener('click', async () => { - const action = button.getAttribute('data-action') || ''; - const agentId = button.getAttribute('data-agent-id') || ''; - if (!agentId || !state.projectId) { - return; - } - - if (action === 'stop') { - await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/stop'), { method: 'POST' }); - await loadAgents(); - return; - } - - if (action === 'logs') { - const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/logs?lines=250')); - const lines = (payload.entries || []).map((entry) => entry.at + ' [' + entry.stream + '] ' + entry.line); - setProjectOutput(lines.join('\\n')); - return; - } - - if (action === 'attach') { - const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/attach')); - setProjectOutput(JSON.stringify(payload.attach, null, 2)); - } - }); - }); - }; - - const clearEvents = () => { - views.eventsLog.textContent = ''; - }; - - const appendEvent = (event, payload) => { - const line = event + ' ' + JSON.stringify(payload); - views.eventsLog.textContent = (line + '\\n' + views.eventsLog.textContent).slice(0, 24000); - }; - - const stopEventStream = () => { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - appendEvent('system', { message: 'events stopped' }); - } - }; - - const startEventStream = () => { - if (!state.projectId) { - throw new Error('Выберите проект перед запуском SSE'); - } - - stopEventStream(); - const base = normalizeBase(views.baseUrl.value); - const url = base + projectPath(state.projectId, '/events?cursor=' + state.eventCursor); - const source = new EventSource(url); - state.eventSource = source; - - source.onmessage = (event) => { - if (!event.data) { - return; - } - try { - const payload = JSON.parse(event.data); - if (payload && payload.seq) { - state.eventCursor = payload.seq; - } - appendEvent(event.type || 'message', payload); - } catch (_error) { - appendEvent(event.type || 'message', event.data); - } - }; - - source.addEventListener('snapshot', (event) => { - try { - const payload = JSON.parse(event.data || '{}'); - state.eventCursor = payload.cursor || state.eventCursor; - appendEvent('snapshot', payload); - } catch (_error) { - appendEvent('snapshot', event.data || ''); - } - }); - - source.onerror = () => { - appendEvent('system', { message: 'events connection error' }); - }; - - appendEvent('system', { message: 'events started', url }); - }; - - const actionProject = async (suffix, method) => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - await request(projectPath(state.projectId, suffix), { method: method || 'POST' }); - await loadProject(); - await loadProjects(); - }; - - const runProjectRead = async (suffix) => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - const payload = await request(projectPath(state.projectId, suffix)); - setProjectOutput(payload.output || ''); - }; - - const parseEnvLines = (raw) => { - return String(raw || '') - .split(/\\r?\\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && line.includes('=')) - .map((line) => { - const idx = line.indexOf('='); - return { key: line.slice(0, idx).trim(), value: line.slice(idx + 1) }; - }) - .filter((entry) => entry.key.length > 0); - }; - - const createProject = async () => { - const body = { - repoUrl: views.createRepoUrl.value.trim() || undefined, - repoRef: views.createRepoRef.value.trim() || undefined, - sshPort: views.createSshPort.value.trim() || undefined, - cpuLimit: views.createCpu.value.trim() || undefined, - ramLimit: views.createRam.value.trim() || undefined, - dockerNetworkMode: views.createNetworkMode.value.trim() || undefined, - up: views.createUp.checked, - force: views.createForce.checked, - forceEnv: views.createForceEnv.checked, - openSsh: false - }; - - await request('/projects', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }); - - await loadProjects(); - }; - - const createAgent = async () => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - - const body = { - provider: views.agentProvider.value, - label: views.agentLabel.value.trim() || undefined, - command: views.agentCommand.value.trim() || undefined, - cwd: views.agentCwd.value.trim() || undefined, - env: parseEnvLines(views.agentEnv.value) - }; - - await request(projectPath(state.projectId, '/agents'), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }); - - await loadAgents(); - }; - - const withUiError = (fn) => async () => { - try { - await fn(); - } catch (error) { - appendDebug('UI error', String(error)); - window.alert(String(error)); - } - }; - - const wireActions = () => { - byId('btn-clear-output').addEventListener('click', () => { - views.debugOutput.textContent = ''; - views.eventsLog.textContent = ''; - views.projectOutput.textContent = ''; - }); - - byId('btn-health').addEventListener('click', withUiError(async () => { - const payload = await request('/health'); - window.alert('Health: ' + JSON.stringify(payload)); - })); - - byId('btn-projects-refresh').addEventListener('click', withUiError(loadProjects)); - byId('btn-create-project').addEventListener('click', withUiError(createProject)); - - byId('btn-up').addEventListener('click', withUiError(() => actionProject('/up', 'POST'))); - byId('btn-down').addEventListener('click', withUiError(() => actionProject('/down', 'POST'))); - byId('btn-recreate').addEventListener('click', withUiError(() => actionProject('/recreate', 'POST'))); - byId('btn-delete').addEventListener('click', withUiError(async () => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - const ok = window.confirm('Удалить проект ' + state.projectId + '?'); - if (!ok) { - return; - } - await request(projectPath(state.projectId, ''), { method: 'DELETE' }); - stopEventStream(); - state.projectId = ''; - state.project = null; - state.agents = []; - renderProjectDetails(); - renderAgents(); - await loadProjects(); - })); - - byId('btn-ps').addEventListener('click', withUiError(() => runProjectRead('/ps'))); - byId('btn-logs').addEventListener('click', withUiError(() => runProjectRead('/logs'))); - - byId('btn-events-start').addEventListener('click', withUiError(async () => { - clearEvents(); - startEventStream(); - })); - byId('btn-events-stop').addEventListener('click', () => stopEventStream()); - - byId('btn-agent-start').addEventListener('click', withUiError(createAgent)); - byId('btn-agents-refresh').addEventListener('click', withUiError(loadAgents)); - }; - - const bootstrap = async () => { - views.baseUrl.value = window.location.origin; - wireActions(); - renderProjectDetails(); - renderAgents(); - await loadProjects(); - }; - - window.addEventListener('beforeunload', () => stopEventStream()); - - bootstrap().catch((error) => { - appendDebug('bootstrap failure', String(error)); - }); -})(); -` diff --git a/packages/api/tests/api-console-routes.test.ts b/packages/api/tests/api-console-routes.test.ts new file mode 100644 index 00000000..b3bea75f --- /dev/null +++ b/packages/api/tests/api-console-routes.test.ts @@ -0,0 +1,29 @@ +import * as HttpApp from "@effect/platform/HttpApp" +import * as HttpRouter from "@effect/platform/HttpRouter" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { makeRouter } from "../src/http.js" + +const apiHandler = HttpApp.toWebHandler( + Effect.provide(Effect.flatten(HttpRouter.toHttpApp(makeRouter())), NodeContext.layer) +) + +const requestApiRoute = (path: string) => + Effect.tryPromise({ + try: () => apiHandler(new Request(`http://127.0.0.1${path}`)), + catch: (cause) => new Error(String(cause)) + }) + +describe("api console routes", () => { + it.effect("does not serve the legacy built-in API console", () => + Effect.gen(function*(_) { + const routes = ["/", "/ui/styles.css", "/ui/app.js"] as const + + for (const route of routes) { + const response = yield* _(requestApiRoute(route)) + expect(response.status).toBe(404) + } + })) +}) diff --git a/packages/api/tests/ui.test.ts b/packages/api/tests/ui.test.ts deleted file mode 100644 index c876de38..00000000 --- a/packages/api/tests/ui.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" - -import { uiHtml, uiScript, uiStyles } from "../src/ui.js" - -describe("api ui wrapper", () => { - it.effect("contains basic shell and API hooks", () => - Effect.sync(() => { - expect(uiHtml).toContain("docker-git API Console") - expect(uiHtml).toContain("/ui/app.js") - expect(uiScript).toContain("/projects") - expect(uiStyles).toContain(".panel") - })) -})