diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..7a08584 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine AS web-build +WORKDIR /app +COPY package*.json ./ +RUN npm ci --silent +COPY . . +RUN npm run build + +FROM node:18-alpine AS web-runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=web-build /app/ . +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/web/components/Editor.tsx b/web/components/Editor.tsx new file mode 100644 index 0000000..688a757 --- /dev/null +++ b/web/components/Editor.tsx @@ -0,0 +1,24 @@ +import dynamic from 'next/dynamic' +import React from 'react' + +const Monaco = dynamic(() => import('@monaco-editor/react'), { ssr: false }) + +type Props = { + value: string + language?: string + onChange?: (v: string | undefined) => void + height?: string | number +} + +export default function Editor({ value, language = 'yaml', onChange, height = '60vh' }: Props) { + return ( + // @ts-ignore - dynamic import types + + ) +} diff --git a/web/lib/ws.ts b/web/lib/ws.ts new file mode 100644 index 0000000..7944c7f --- /dev/null +++ b/web/lib/ws.ts @@ -0,0 +1,15 @@ +export function connectWS(room: string, onMessage: (m: string) => void) { + const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080' + const url = backend.replace(/^http/, 'ws') + `/ws?room=${encodeURIComponent(room)}` + try { + const ws = new WebSocket(url) + ws.addEventListener('message', (ev) => onMessage(ev.data)) + ws.addEventListener('open', () => onMessage('[ws] connected')) + ws.addEventListener('close', () => onMessage('[ws] disconnected')) + ws.addEventListener('error', () => onMessage('[ws] error')) + return ws + } catch (err) { + onMessage('[ws] connection failed') + return null + } +} diff --git a/web/next.config.js b/web/next.config.js new file mode 100644 index 0000000..a843cbe --- /dev/null +++ b/web/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +module.exports = nextConfig diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c7d107c --- /dev/null +++ b/web/package.json @@ -0,0 +1,19 @@ +{ + "name": "nexus-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -p $PORT" + }, + "dependencies": { + "next": "14.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "@monaco-editor/react": "^5.0.1" + }, + "devDependencies": { + "typescript": "5.5.0" + } +} diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx new file mode 100644 index 0000000..c055f25 --- /dev/null +++ b/web/pages/_app.tsx @@ -0,0 +1,6 @@ +import '../styles/globals.css' +import type { AppProps } from 'next/app' + +export default function App({ Component, pageProps }: AppProps) { + return +} diff --git a/web/pages/collections/[name].tsx b/web/pages/collections/[name].tsx new file mode 100644 index 0000000..7637ed3 --- /dev/null +++ b/web/pages/collections/[name].tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { connectWS } from '../../lib/ws' +import Editor from '../../components/Editor' + +export default function CollectionPage() { + const router = useRouter() + const name = Array.isArray(router.query.name) ? router.query.name[0] : router.query.name || '' + + const [content, setContent] = useState('') + const [status, setStatus] = useState(null) + const [wsLog, setWsLog] = useState([]) + const [ws, setWs] = useState(null) + + useEffect(() => { + if (!name) return + const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080' + fetch(`${backend}/api/collections/get?name=${encodeURIComponent(name)}`) + .then((r) => r.json()) + .then((data) => { + setContent(JSON.stringify(data, null, 2)) + }) + .catch((e) => setStatus(String(e))) + + const socket = connectWS(name, (m) => setWsLog((s) => [...s, m])) + setWs(socket) + return () => { + try { + socket?.close() + } catch {} + } + }, [name]) + + function save() { + const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080' + fetch(`${backend}/api/collections/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, content }), + }) + .then((r) => r.json()) + .then(() => setStatus('saved')) + .catch((e) => setStatus(String(e))) + } + + function run() { + const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080' + setStatus('running') + fetch(`${backend}/api/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + .then((r) => r.json()) + .then((data) => setStatus(JSON.stringify(data))) + .catch((e) => setStatus(String(e))) + } + + return ( + + Collection: {name} + + Save + Run + + + setContent(v ?? '')} height="60vh" /> + + + Status: {status} + + + + WS log: + + {wsLog.map((l, i) => ( + {l} + ))} + + + + ) +} diff --git a/web/pages/collections/index.tsx b/web/pages/collections/index.tsx new file mode 100644 index 0000000..7d02ac7 --- /dev/null +++ b/web/pages/collections/index.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import Link from 'next/link' +import type { GetServerSideProps } from 'next' + +type Props = { collections: string[] } + +export default function Collections({ collections }: Props) { + return ( + + Collections + + {collections.map((c) => ( + + {c} + + ))} + + + ) +} + +export const getServerSideProps: GetServerSideProps = async () => { + const backend = process.env.BACKEND_URL || 'http://localhost:8080' + try { + const res = await fetch(`${backend}/api/collections`) + const data = await res.json() + return { props: { collections: data.collections || [] } } + } catch (err) { + return { props: { collections: [] } } + } +} diff --git a/web/pages/index.tsx b/web/pages/index.tsx new file mode 100644 index 0000000..0faaef3 --- /dev/null +++ b/web/pages/index.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import type { GetServerSideProps } from 'next' + +type Props = { + collections: string[] + error?: string +} + +export default function Home({ collections, error }: Props) { + if (error) return Error: {error} + + return ( + + Nexus — Collections + Collections stored in repository + + {collections.map((c) => ( + {c} + ))} + + + ) +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const backend = process.env.BACKEND_URL || 'http://localhost:8080' + try { + const res = await fetch(`${backend}/api/collections`) + if (!res.ok) { + return { props: { collections: [], error: `backend responded ${res.status}` } } + } + const data = await res.json() + return { props: { collections: data.collections || [] } } + } catch (err: any) { + return { props: { collections: [], error: err.message } } + } +} diff --git a/web/styles/globals.css b/web/styles/globals.css new file mode 100644 index 0000000..e997c4b --- /dev/null +++ b/web/styles/globals.css @@ -0,0 +1,7 @@ +html, body, #__next { + height: 100%; +} +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..b657a4a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}
Collections stored in repository