feat: add control-plane, web, and docker-compose
- Add control-plane service with Dockerfile and internal packages - Add web application (Next.js) - Add docker-compose.yml for orchestration - Update .gitignore and README.md
This commit is contained in:
parent
05a7491300
commit
ed882cf6a4
27 changed files with 2491 additions and 14 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -5,3 +5,10 @@ agent-ops/rules/private/
|
|||
# Local runtime/test artifacts
|
||||
/iop.db
|
||||
/*.log
|
||||
|
||||
# Web Portal artifacts
|
||||
apps/web/node_modules/
|
||||
apps/web/.next/
|
||||
apps/web/out/
|
||||
apps/web/.env*.local
|
||||
apps/web/*.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -206,14 +206,17 @@ NomadCode
|
|||
|
||||
| 경로 | 현재 의미 |
|
||||
|---|---|
|
||||
| `apps/web` | IOP 전체 Web Portal을 위한 Next.js 스캐폴드 |
|
||||
| `apps/node` | Edge에 연결되어 adapter execution을 수행하는 Node agent |
|
||||
| `apps/edge` | Node registry, 설정 전달, routing, stream relay를 담당하는 Edge skeleton |
|
||||
| `apps/control-plane` | 향후 여러 Edge를 연결하고 운영 화면을 제공할 중앙 관리 계층 |
|
||||
| `apps/control-plane` | 여러 Edge를 연결하고 Portal과 통신할 Go 기반 운영 제어 서버 스캐폴드 |
|
||||
| `apps/worker` | 현재 placeholder이며, Worker 구조는 우선 각 Go 서비스 내부 공통 모듈 방향으로 둔다 |
|
||||
| `packages` | 설정, 인증, 정책, 작업, 관측성, 버전 등 공통 패키지 |
|
||||
| `proto` | 앱 간 메시지 계약 원본과 생성물 |
|
||||
| `configs` | 현재 개발용 설정 예시 |
|
||||
|
||||
`apps/web`은 IOP 전체 Web Portal이며, `apps/control-plane`은 Go 기반 운영 제어 서버다. 주요 통신은 edge-node에서 사용 중인 protobuf-socket을 IOP Wire Protocol 기준으로 Portal-Control Plane, Control Plane-Edge, Edge-Node 방향으로 확장한다. `net/http`는 health/readiness/bootstrap 같은 보조 endpoint 용도로 유지한다.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1. Edge-Node execution skeleton
|
||||
|
|
|
|||
29
apps/control-plane/Dockerfile
Normal file
29
apps/control-plane/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /workspace/go-iop
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# docker-compose builds this Dockerfile with /config/workspace as context so
|
||||
# the local ../proto-socket/go replace in go.mod is available.
|
||||
COPY go-iop/go.mod go-iop/go.sum ./
|
||||
COPY proto-socket/go /workspace/proto-socket/go
|
||||
RUN go mod download
|
||||
|
||||
COPY go-iop/ ./
|
||||
RUN CGO_ENABLED=0 GOFLAGS=-trimpath go build -o /out/control-plane ./apps/control-plane/cmd/control-plane
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN addgroup -S iop && adduser -S iop -G iop
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/control-plane /usr/local/bin/control-plane
|
||||
COPY --from=builder /workspace/go-iop/configs/control-plane.yaml /app/configs/control-plane.yaml
|
||||
|
||||
EXPOSE 9080 19080
|
||||
|
||||
USER iop
|
||||
ENTRYPOINT ["control-plane"]
|
||||
CMD ["serve", "--config", "/app/configs/control-plane.yaml"]
|
||||
|
|
@ -2,11 +2,17 @@
|
|||
|
||||
여러 Edge를 연결하고 관찰하며, Edge 설정 변경과 명령 전달을 담당할 Go 기반 중앙 관리 서버다.
|
||||
|
||||
**현재 상태: Placeholder**
|
||||
**현재 상태: Scaffold**
|
||||
|
||||
현재 Control Plane은 구현 전 단계다. Node를 직접 등록하거나 직접 스케줄링하는 계층으로 확정하지 않는다. Control Plane은 Edge를 통해 시스템을 제어하고, Edge가 자신의 로컬 런타임 상태와 Node registry를 운영하는 방향을 따른다.
|
||||
현재 Control Plane은 실행 가능한 최소 서버 스캐폴드 단계다. Node를 직접 등록하거나 직접 스케줄링하는 계층으로 확정하지 않는다. Control Plane은 Edge를 통해 시스템을 제어하고, Edge가 자신의 로컬 런타임 상태와 Node registry를 운영하는 방향을 따른다.
|
||||
|
||||
IOP의 프론트엔드는 Control Plane 내부 UI가 아니라 별도 React + Next.js 기반 Web Portal로 둔다. Portal은 별도 Docker 컨테이너로 운영하며, 초기에는 Control Plane API를 중심으로 연동한다.
|
||||
IOP의 프론트엔드는 Control Plane 내부 UI가 아니라 `apps/web`의 React + Next.js 기반 Web Portal로 둔다. Portal은 별도 Docker 컨테이너로 운영하며, Control Plane과의 주요 통신은 edge-node에서 사용 중인 protobuf-socket을 IOP Wire Protocol 기준으로 확장한다. `net/http`는 health/readiness/bootstrap 같은 보조 endpoint 용도로만 둔다.
|
||||
|
||||
## 현재 실행 표면
|
||||
|
||||
- `GET /healthz`
|
||||
- `GET /readyz`
|
||||
- protobuf-socket wire endpoint 예약: `server.wire_listen`
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,34 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"iop/apps/control-plane/internal/wire"
|
||||
"iop/packages/observability"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
type controlPlaneConfig struct {
|
||||
Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
WireListen string `yaml:"wire_listen"`
|
||||
} `yaml:"server"`
|
||||
Logging struct {
|
||||
Level string `yaml:"level"`
|
||||
Pretty bool `yaml:"pretty"`
|
||||
} `yaml:"logging"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
|
|
@ -17,19 +39,106 @@ func main() {
|
|||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "control-plane",
|
||||
Short: "IOP Control Plane — multi-edge management placeholder",
|
||||
Short: "IOP Control Plane - multi-edge management server",
|
||||
Long: `control-plane will manage Edge connections, observe Edge state,
|
||||
adjust Edge configuration, deliver commands to Edge, and receive Edge events.
|
||||
|
||||
This component is a placeholder. It should control the system through Edge,
|
||||
not by connecting to or scheduling Node directly.`,
|
||||
It controls the system through Edge, not by connecting to or scheduling Node
|
||||
directly. The main IOP communication layer is protobuf-socket; net/http is kept
|
||||
for health, readiness, and bootstrap endpoints.`,
|
||||
}
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the control-plane server",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
fmt.Println("control-plane serve: not yet implemented")
|
||||
},
|
||||
})
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/control-plane.yaml", "config file path")
|
||||
root.AddCommand(serveCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func serveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the control-plane server",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := loadConfig(cfgFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
logger, err := observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logger: %w", err)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
return run(ctx, cfg, logger)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() controlPlaneConfig {
|
||||
var cfg controlPlaneConfig
|
||||
cfg.Server.Listen = "0.0.0.0:9080"
|
||||
cfg.Server.WireListen = "0.0.0.0:19080"
|
||||
cfg.Logging.Level = "info"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadConfig(path string) (controlPlaneConfig, error) {
|
||||
cfg := defaultConfig()
|
||||
if path == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
})
|
||||
|
||||
wireEndpoint := wire.Endpoint{Listen: cfg.Server.WireListen}
|
||||
logger.Info("control-plane wire endpoint reserved",
|
||||
zap.String("protocol", wire.Protocol),
|
||||
zap.String("listen", wireEndpoint.Listen),
|
||||
)
|
||||
// TODO(control-plane): start the protobuf-socket listener here and use it
|
||||
// for Portal-Control Plane and Control Plane-Edge message sessions.
|
||||
// Keep net/http limited to health, readiness, and bootstrap endpoints.
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("control-plane http endpoint listening", zap.String("listen", cfg.Server.Listen))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
apps/control-plane/internal/wire/wire.go
Normal file
13
apps/control-plane/internal/wire/wire.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package wire
|
||||
|
||||
// Protocol names the IOP standard communication layer. The current Edge-Node
|
||||
// transport already uses common-proto-socket; Control Plane endpoints should
|
||||
// extend that same wire protocol instead of introducing another RPC framework.
|
||||
const Protocol = "protobuf-socket"
|
||||
|
||||
// Endpoint is the reserved Control Plane wire endpoint configuration.
|
||||
// TODO(control-plane): replace this scaffold with a protobuf-socket server
|
||||
// once Portal-Control Plane and Control Plane-Edge message contracts settle.
|
||||
type Endpoint struct {
|
||||
Listen string
|
||||
}
|
||||
6
apps/web/.dockerignore
Normal file
6
apps/web/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
*.tsbuildinfo
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
34
apps/web/Dockerfile
Normal file
34
apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup -S iop && adduser -S iop -G iop
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
USER iop
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
20
apps/web/README.md
Normal file
20
apps/web/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# web - IOP Web Portal
|
||||
|
||||
`apps/web`은 IOP 전체 Web Portal을 위한 Next.js 스캐폴드다. 현재는 root dummy 화면만 제공하며, `/nodes`, `/models`, `/jobs` 같은 도메인 페이지는 아직 만들지 않는다.
|
||||
|
||||
Control Plane과의 주요 통신은 edge-node에서 사용 중인 protobuf-socket을 IOP Wire Protocol 기준으로 확장한다. 현재 `src/lib/iop-client/`에는 TypeScript 클라이언트 연결 위치와 TODO만 둔다. 브라우저 직접 WebSocket transport가 확정되기 전까지는 Next.js server-side bridge가 필요할 수 있다.
|
||||
|
||||
## Local
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
루트에서 실행한다.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
21
apps/web/components.json
Normal file
21
apps/web/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
apps/web/next.config.ts
Normal file
7
apps/web/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1808
apps/web/package-lock.json
generated
Normal file
1808
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
apps/web/package.json
Normal file
30
apps/web/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@iop/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "node .next/standalone/server.js",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "1.16.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"tailwind-merge": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/node": "25.8.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.3.0",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/web/public/.gitkeep
Normal file
1
apps/web/public/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
86
apps/web/src/app/globals.css
Normal file
86
apps/web/src/app/globals.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.985 0.004 106);
|
||||
--foreground: oklch(0.18 0.015 250);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.18 0.015 250);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.18 0.015 250);
|
||||
--primary: oklch(0.47 0.13 220);
|
||||
--primary-foreground: oklch(0.99 0.005 210);
|
||||
--secondary: oklch(0.92 0.016 190);
|
||||
--secondary-foreground: oklch(0.23 0.026 230);
|
||||
--muted: oklch(0.94 0.01 220);
|
||||
--muted-foreground: oklch(0.46 0.025 235);
|
||||
--accent: oklch(0.88 0.09 165);
|
||||
--accent-foreground: oklch(0.2 0.04 175);
|
||||
--destructive: oklch(0.57 0.22 25);
|
||||
--border: oklch(0.87 0.012 230);
|
||||
--input: oklch(0.87 0.012 230);
|
||||
--ring: oklch(0.58 0.14 215);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.16 0.017 245);
|
||||
--foreground: oklch(0.96 0.008 220);
|
||||
--card: oklch(0.2 0.02 245);
|
||||
--card-foreground: oklch(0.96 0.008 220);
|
||||
--popover: oklch(0.2 0.02 245);
|
||||
--popover-foreground: oklch(0.96 0.008 220);
|
||||
--primary: oklch(0.72 0.13 210);
|
||||
--primary-foreground: oklch(0.17 0.02 245);
|
||||
--secondary: oklch(0.27 0.025 235);
|
||||
--secondary-foreground: oklch(0.94 0.008 220);
|
||||
--muted: oklch(0.26 0.022 235);
|
||||
--muted-foreground: oklch(0.72 0.025 225);
|
||||
--accent: oklch(0.72 0.11 165);
|
||||
--accent-foreground: oklch(0.17 0.02 245);
|
||||
--destructive: oklch(0.66 0.2 25);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.72 0.13 210);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
20
apps/web/src/app/layout.tsx
Normal file
20
apps/web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "IOP Portal",
|
||||
description: "IOP Web Portal scaffold",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/app/page.tsx
Normal file
96
apps/web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Activity, Cable, Server, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getControlPlaneHealth, getIopClientConfig } from "@/lib/iop-client/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const config = getIopClientConfig();
|
||||
const health = await getControlPlaneHealth(config);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-5">
|
||||
<header className="flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Activity className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Inference Operations Platform</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">IOP Portal</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<ShieldCheck className="size-4" aria-hidden="true" />
|
||||
Scaffold
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section className="grid flex-1 content-center gap-6 py-10 md:grid-cols-[1.35fr_0.65fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<span
|
||||
className={`size-2 rounded-full ${health.ok ? "bg-emerald-500" : "bg-amber-500"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Control Plane {health.label}
|
||||
</div>
|
||||
<div className="max-w-3xl space-y-3">
|
||||
<h2 className="text-4xl font-semibold tracking-normal md:text-5xl">운영 포털 진입점</h2>
|
||||
<p className="text-lg leading-8 text-muted-foreground">
|
||||
IOP 전체 Web Portal을 위한 최소 Next.js 스캐폴드입니다. 세부 도메인 화면은 아직 고정하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatusCard icon={Server} label="Server" value="Go Control Plane" />
|
||||
<StatusCard icon={Cable} label="Wire" value="protobuf-socket" />
|
||||
<StatusCard icon={Activity} label="Portal" value="Next.js" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="self-center rounded-lg border border-border bg-card p-5 shadow-sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Control Plane HTTP</p>
|
||||
<p className="mt-1 break-all text-sm font-semibold">{config.publicHttpUrl}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">IOP Wire Endpoint</p>
|
||||
<p className="mt-1 break-all text-sm font-semibold">{config.wireUrl}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-secondary p-3 text-sm text-secondary-foreground">
|
||||
<p className="font-medium">Connection scaffold</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
protobuf-socket TS client entrypoints live under <code>src/lib/iop-client</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: typeof Server;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<Icon className="size-5 text-primary" aria-hidden="true" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-base font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/ui/button.tsx
Normal file
45
apps/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex h-9 items-center justify-center gap-2 whitespace-nowrap rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
outline: "border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-secondary hover:text-secondary-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-sm",
|
||||
icon: "size-9 px-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
24
apps/web/src/lib/iop-client/client.ts
Normal file
24
apps/web/src/lib/iop-client/client.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { IopClientConfig } from "./types";
|
||||
|
||||
export function getIopClientConfig(): IopClientConfig {
|
||||
const publicHttpUrl = process.env.NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL ?? "http://localhost:9080";
|
||||
|
||||
return {
|
||||
publicHttpUrl,
|
||||
internalHttpUrl: process.env.CONTROL_PLANE_INTERNAL_HTTP_URL ?? publicHttpUrl,
|
||||
wireUrl: process.env.NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL ?? "tcp://localhost:19080",
|
||||
protocol: "protobuf-socket",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getControlPlaneHealth(config: IopClientConfig): Promise<{ ok: boolean; label: string }> {
|
||||
try {
|
||||
const res = await fetch(`${config.internalHttpUrl}/healthz`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
return { ok: res.ok, label: res.ok ? "ready" : "unavailable" };
|
||||
} catch {
|
||||
return { ok: false, label: "pending" };
|
||||
}
|
||||
}
|
||||
10
apps/web/src/lib/iop-client/connection.ts
Normal file
10
apps/web/src/lib/iop-client/connection.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { IopConnection, IopConnectionOptions } from "./types";
|
||||
|
||||
export async function connectIopWire(options: IopConnectionOptions): Promise<IopConnection> {
|
||||
void options;
|
||||
// TODO(web): wire this to the TypeScript protobuf-socket client once the
|
||||
// browser transport is confirmed. The current checked-in TS implementation
|
||||
// uses Node.js TCP/ws helpers; if that remains true, add a Next.js
|
||||
// server-side bridge and keep browser code talking to the bridge.
|
||||
throw new Error("IOP protobuf-socket client is not connected yet.");
|
||||
}
|
||||
21
apps/web/src/lib/iop-client/types.ts
Normal file
21
apps/web/src/lib/iop-client/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export type IopWireProtocol = "protobuf-socket";
|
||||
|
||||
export type IopWireTransport = "tcp" | "websocket" | "server-bridge";
|
||||
|
||||
export interface IopClientConfig {
|
||||
publicHttpUrl: string;
|
||||
internalHttpUrl: string;
|
||||
wireUrl: string;
|
||||
protocol: IopWireProtocol;
|
||||
}
|
||||
|
||||
export interface IopConnectionOptions {
|
||||
url: string;
|
||||
transport: IopWireTransport;
|
||||
}
|
||||
|
||||
export interface IopConnection {
|
||||
protocol: IopWireProtocol;
|
||||
transport: IopWireTransport;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
33
apps/web/tsconfig.json
Normal file
33
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
server:
|
||||
listen: "0.0.0.0:9080"
|
||||
wire_listen: "0.0.0.0:19080"
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
pretty: true
|
||||
|
||||
metrics:
|
||||
port: 9093
|
||||
|
|
|
|||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
services:
|
||||
control-plane:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: go-iop/apps/control-plane/Dockerfile
|
||||
ports:
|
||||
- "9080:9080"
|
||||
- "19080:19080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9080/healthz"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./apps/web
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
CONTROL_PLANE_INTERNAL_HTTP_URL: "http://control-plane:9080"
|
||||
NEXT_PUBLIC_CONTROL_PLANE_HTTP_URL: "http://localhost:9080"
|
||||
NEXT_PUBLIC_CONTROL_PLANE_WIRE_URL: "tcp://localhost:19080"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
control-plane:
|
||||
condition: service_healthy
|
||||
Loading…
Reference in a new issue