HH-738: add constrained browser container control (#4)

This commit is contained in:
2026-08-27 18:07:34 +08:00
parent 8aa194b264
commit 4b054d6f04
16 changed files with 1925 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
.gitea
web/node_modules
web/dist
+2
View File
@@ -0,0 +1,2 @@
web/dist/
web/node_modules/
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine AS web
WORKDIR /src/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.26-alpine AS go
WORKDIR /src
COPY go.mod ./
COPY cmd/ ./cmd/
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway
FROM alpine:3.22
RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app
WORKDIR /app
COPY --from=go /out/control-plane /out/docker-gateway /app/
COPY --from=web /src/web/dist /app/web
USER 65532:65532
ENV WEB_DIR=/app/web
EXPOSE 8080 8081
CMD ["/app/control-plane"]
+22 -1
View File
@@ -4,11 +4,32 @@
## 当前阶段
项目处于规划与初始化阶段。功能参考公开上游项目独立实现,不直接复制其代码。
项目处于阶段 A 技术验证。首个可运行切片提供浏览器容器的创建、列表、启停和回收,功能参考公开上游项目独立实现,不直接复制其代码。
详细的五领域优先级、系统边界、合规知识库与分阶段验证方案见
[《合规产品规划与上游调研》](docs/product/compliance-product-plan.md)。
## 本地运行
需要 Docker Compose。控制面仅绑定本机;浏览器网络在阶段 A 默认禁止外网访问:
```bash
docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215
DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build
```
打开 <http://127.0.0.1:8080>。架构、API 契约、失败语义和 `docker.sock` 风险边界见
[《浏览器容器控制面》](docs/architecture/container-control.md)。
最小验证:
```bash
go test ./...
npm --prefix web ci
npm --prefix web run build
docker compose config --quiet
```
## 合规边界
- 自动化行为必须遵守目标平台规则、适用法律和账号授权范围。
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
gateway, err := url.Parse(env("DOCKER_GATEWAY_URL", "http://127.0.0.1:8081"))
if err != nil {
log.Fatal(err)
}
proxy := httputil.NewSingleHostReverseProxy(gateway)
originalDirector := proxy.Director
proxy.Director = func(request *http.Request) {
originalDirector(request)
request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers")
request.Host = gateway.Host
}
mux := http.NewServeMux()
mux.Handle("/api/browsers", proxy)
mux.Handle("/api/browsers/", proxy)
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
mux.Handle("/", spaHandler(env("WEB_DIR", "web/dist")))
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8080"),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("CreatorHub control plane listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}
func spaHandler(directory string) http.Handler {
files := http.FileServer(http.Dir(directory))
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
name := filepath.Join(directory, filepath.Clean(strings.TrimPrefix(request.URL.Path, "/")))
if info, err := os.Stat(name); err == nil && !info.IsDir() {
files.ServeHTTP(response, request)
return
}
http.ServeFile(response, request, filepath.Join(directory, "index.html"))
})
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+400
View File
@@ -0,0 +1,400 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
const (
managedLabel = "io.creatorhub.managed"
idLabel = "io.creatorhub.runtime-id"
networkRoleLabel = "io.creatorhub.network-role"
browserNetworkRole = "browser"
controlNetworkName = "creatorhub_control"
bridgeICCOption = "com.docker.network.bridge.enable_icc"
namePrefix = "creatorhub-browser-"
)
var runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
type config struct {
image string
network string
}
type dockerClient struct {
baseURL string
client *http.Client
}
type gateway struct {
docker dockerClient
config config
}
type createRequest struct {
Name string `json:"name"`
Seed int64 `json:"seed"`
}
type browser struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Status string `json:"status"`
Profile string `json:"profile"`
Endpoint string `json:"endpoint"`
}
func main() {
image := os.Getenv("BROWSER_IMAGE")
if image == "" || strings.HasSuffix(image, ":latest") || !strings.Contains(image, ":") {
log.Fatal("BROWSER_IMAGE must be a pinned image tag")
}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock"))
},
}
docker := dockerClient{
baseURL: "http://docker/v1.43",
client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
}
network := env("BROWSER_NETWORK", "creatorhub_browser")
if err := docker.ensureInternalNetwork(network); err != nil {
log.Fatal(err)
}
handler := newGateway(docker, config{image: image, network: network})
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8081"),
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("restricted Docker gateway listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}
func newGateway(client dockerClient, cfg config) http.Handler {
api := gateway{docker: client, config: cfg}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /v1/browsers", api.list)
mux.HandleFunc("POST /v1/browsers", api.create)
mux.HandleFunc("POST /v1/browsers/{id}/{action}", api.changeState)
mux.HandleFunc("DELETE /v1/browsers/{id}", api.remove)
return mux
}
func (api gateway) list(response http.ResponseWriter, _ *http.Request) {
filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}})
result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil)
if err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
defer result.Body.Close()
if result.StatusCode != http.StatusOK {
forwardDockerError(response, result)
return
}
var containers []struct {
ID string `json:"Id"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
if err := json.NewDecoder(result.Body).Decode(&containers); err != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err))
return
}
browsers := make([]browser, 0, len(containers))
for _, container := range containers {
id := container.Labels[idLabel]
if !runtimeIDPattern.MatchString(id) {
continue
}
browsers = append(browsers, browser{
ID: container.ID,
Name: id,
State: container.State,
Status: container.Status,
Profile: "creatorhub-profile-" + id,
Endpoint: "http://" + namePrefix + id + ":9222",
})
}
writeJSON(response, http.StatusOK, browsers)
}
func (api gateway) create(response http.ResponseWriter, request *http.Request) {
var input createRequest
decoder := json.NewDecoder(http.MaxBytesReader(response, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeError(response, http.StatusBadRequest, errors.New("body must contain only name and numeric seed"))
return
}
if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 {
writeError(response, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..2147483647"))
return
}
pidsLimit := int64(512)
payload := map[string]any{
"Image": api.config.image,
"Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"},
"Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
"Labels": map[string]string{
managedLabel: "true",
idLabel: input.Name,
},
"ExposedPorts": map[string]any{"9222/tcp": map[string]any{}},
"HostConfig": map[string]any{
"NetworkMode": api.config.network,
"ReadonlyRootfs": true,
"CapDrop": []string{"ALL"},
"SecurityOpt": []string{"no-new-privileges"},
"PidsLimit": &pidsLimit,
"Memory": int64(1 << 30),
"NanoCpus": int64(2_000_000_000),
"Tmpfs": map[string]string{
"/tmp": "rw,nosuid,nodev,noexec,mode=1777,size=256m",
"/tmp/.X11-unix": "rw,nosuid,nodev,noexec,mode=1777,size=1m",
"/dev/shm": "rw,nosuid,nodev,noexec,size=256m",
"/home/ubuntu": "rw,nosuid,nodev,noexec,uid=1000,gid=1000,mode=700,size=64m",
},
"Mounts": []map[string]any{{
"Type": "volume",
"Source": "creatorhub-profile-" + input.Name,
"Target": "/data",
}},
},
}
result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Name), payload)
if err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
defer result.Body.Close()
if result.StatusCode != http.StatusCreated {
forwardDockerError(response, result)
return
}
var created struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" {
writeError(response, http.StatusBadGateway, errors.New("Docker returned an invalid container id"))
return
}
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("container created but did not start: %w", err))
return
}
writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name})
}
func (api gateway) changeState(response http.ResponseWriter, request *http.Request) {
id := request.PathValue("id")
action := request.PathValue("action")
if err := api.requireManaged(id); err != nil {
writeError(response, statusFor(err), err)
return
}
var path string
switch action {
case "start":
path = "/containers/" + url.PathEscape(namePrefix+id) + "/start"
case "stop":
path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10"
default:
writeError(response, http.StatusNotFound, errors.New("unknown action"))
return
}
if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
response.WriteHeader(http.StatusNoContent)
}
func (api gateway) remove(response http.ResponseWriter, request *http.Request) {
id := request.PathValue("id")
if err := api.requireManaged(id); err != nil {
writeError(response, statusFor(err), err)
return
}
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
response.WriteHeader(http.StatusNoContent)
}
func (api gateway) requireManaged(id string) error {
if !runtimeIDPattern.MatchString(id) {
return errors.New("invalid runtime id")
}
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
if err != nil {
return err
}
defer result.Body.Close()
if result.StatusCode == http.StatusNotFound {
return os.ErrNotExist
}
if result.StatusCode != http.StatusOK {
return fmt.Errorf("Docker inspect returned %s", result.Status)
}
var inspected struct {
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
}
if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil {
return fmt.Errorf("decode Docker inspect: %w", err)
}
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
return errors.New("refusing to operate on a container not owned by CreatorHub")
}
return nil
}
func (docker dockerClient) request(method, path string, payload any) (*http.Response, error) {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequest(method, docker.baseURL+path, body)
if err != nil {
return nil, err
}
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
return docker.client.Do(request)
}
func (docker dockerClient) expect(method, path string, payload any, allowed ...int) error {
response, err := docker.request(method, path, payload)
if err != nil {
return err
}
defer response.Body.Close()
for _, status := range allowed {
if response.StatusCode == status {
return nil
}
}
message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message)))
}
func (docker dockerClient) ensureInternalNetwork(name string) error {
if !networkNamePattern.MatchString(name) {
return errors.New("BROWSER_NETWORK is invalid")
}
if name == controlNetworkName {
return errors.New("BROWSER_NETWORK must not reuse the control network")
}
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
if err != nil {
return fmt.Errorf("inspect browser network: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusOK {
var network struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Internal bool `json:"Internal"`
Attachable bool `json:"Attachable"`
Ingress bool `json:"Ingress"`
Options map[string]string `json:"Options"`
Labels map[string]string `json:"Labels"`
}
if err := json.NewDecoder(response.Body).Decode(&network); err != nil {
return fmt.Errorf("decode browser network: %w", err)
}
if network.Name != name || network.Driver != "bridge" || !network.Internal || network.Attachable || network.Ingress ||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole ||
len(network.Options) != 1 || network.Options[bridgeICCOption] != "false" {
return fmt.Errorf("browser network %q is not an isolated CreatorHub bridge", name)
}
return nil
}
if response.StatusCode != http.StatusNotFound {
return fmt.Errorf("inspect browser network returned %s", response.Status)
}
return docker.expect(http.MethodPost, "/networks/create", map[string]any{
"Name": name,
"CheckDuplicate": true,
"Driver": "bridge",
"Internal": true,
"Options": map[string]string{bridgeICCOption: "false"},
"Labels": map[string]string{
managedLabel: "true",
networkRoleLabel: browserNetworkRole,
},
}, http.StatusCreated)
}
func statusFor(err error) int {
if errors.Is(err, os.ErrNotExist) {
return http.StatusNotFound
}
if strings.Contains(err.Error(), "refusing") {
return http.StatusForbidden
}
return http.StatusBadRequest
}
func forwardDockerError(response http.ResponseWriter, result *http.Response) {
message, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
status := http.StatusBadGateway
if result.StatusCode == http.StatusConflict {
status = http.StatusConflict
}
writeError(response, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message))))
}
func writeError(response http.ResponseWriter, status int, err error) {
writeJSON(response, status, map[string]string{"error": err.Error()})
}
func writeJSON(response http.ResponseWriter, status int, value any) {
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(status)
_ = json.NewEncoder(response).Encode(value)
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGatewayCreatesConstrainedBrowser(t *testing.T) {
var created map[string]any
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/create":
if got := request.URL.Query().Get("name"); got != namePrefix+"account-a" {
t.Fatalf("unexpected container name %q", got)
}
if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
t.Fatal(err)
}
response.WriteHeader(http.StatusCreated)
_, _ = response.Write([]byte(`{"Id":"container-id"}`))
case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/container-id/start":
response.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String())
}
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL + "/v1.43", client: docker.Client()}, config{
image: "registry.example/browser:1.2.3",
network: "creatorhub_browser",
})
request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String())
}
if created["Image"] != "registry.example/browser:1.2.3" {
t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"])
}
host := created["HostConfig"].(map[string]any)
if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true {
t.Fatalf("missing container isolation: %#v", host)
}
tmpfs := host["Tmpfs"].(map[string]any)
if tmpfs["/tmp/.X11-unix"] == nil || tmpfs["/home/ubuntu"] == nil {
t.Fatalf("missing writable runtime paths: %#v", tmpfs)
}
labels := created["Labels"].(map[string]any)
if labels[managedLabel] != "true" || labels[idLabel] != "account-a" {
t.Fatalf("missing ownership labels: %#v", labels)
}
}
func TestGatewayRefusesUnmanagedContainer(t *testing.T) {
deleted := false
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
_, _ = response.Write([]byte(`{"Config":{"Labels":{}}}`))
case http.MethodDelete:
deleted = true
response.WriteHeader(http.StatusNoContent)
}
}))
defer docker.Close()
handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{})
request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || deleted {
t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted)
}
}
func TestEnsureInternalNetwork(t *testing.T) {
var created struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Internal bool `json:"Internal"`
Options map[string]string `json:"Options"`
Labels map[string]string `json:"Labels"`
}
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
response.WriteHeader(http.StatusNotFound)
case http.MethodPost:
if err := json.NewDecoder(request.Body).Decode(&created); err != nil {
t.Fatal(err)
}
response.WriteHeader(http.StatusCreated)
}
}))
defer docker.Close()
client := dockerClient{baseURL: docker.URL, client: docker.Client()}
if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil {
t.Fatal(err)
}
if created.Name != "creatorhub_browser" || created.Driver != "bridge" || !created.Internal ||
created.Options[bridgeICCOption] != "false" || len(created.Options) != 1 ||
created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole {
t.Fatalf("network is not isolated: %#v", created)
}
}
func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) {
valid := map[string]any{
"Name": "creatorhub_browser",
"Driver": "bridge",
"Internal": true,
"Attachable": false,
"Ingress": false,
"Options": map[string]string{bridgeICCOption: "false"},
"Labels": map[string]string{
managedLabel: "true",
networkRoleLabel: browserNetworkRole,
},
}
validDocker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(response).Encode(valid)
}))
defer validDocker.Close()
client := dockerClient{baseURL: validDocker.URL, client: validDocker.Client()}
if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil {
t.Fatalf("expected owned isolated network to be accepted: %v", err)
}
tests := map[string]func(map[string]any){
"wrong name": func(network map[string]any) { network["Name"] = "other" },
"wrong driver": func(network map[string]any) { network["Driver"] = "overlay" },
"external": func(network map[string]any) { network["Internal"] = false },
"attachable": func(network map[string]any) { network["Attachable"] = true },
"ingress": func(network map[string]any) { network["Ingress"] = true },
"missing ownership": func(network map[string]any) {
network["Labels"] = map[string]string{networkRoleLabel: browserNetworkRole}
},
"wrong role": func(network map[string]any) {
network["Labels"] = map[string]string{managedLabel: "true", networkRoleLabel: "control"}
},
"missing ICC": func(network map[string]any) { network["Options"] = map[string]string{} },
"enabled ICC": func(network map[string]any) { network["Options"] = map[string]string{bridgeICCOption: "true"} },
"extra option": func(network map[string]any) {
network["Options"] = map[string]string{bridgeICCOption: "false", "unexpected": "value"}
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
network := make(map[string]any, len(valid))
for key, value := range valid {
network[key] = value
}
mutate(network)
docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(response).Encode(network)
}))
defer docker.Close()
client := dockerClient{baseURL: docker.URL, client: docker.Client()}
if err := client.ensureInternalNetwork("creatorhub_browser"); err == nil {
t.Fatal("expected unsafe existing network to be rejected")
}
})
}
}
func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) {
requested := false
docker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requested = true }))
defer docker.Close()
client := dockerClient{baseURL: docker.URL, client: docker.Client()}
if err := client.ensureInternalNetwork(controlNetworkName); err == nil || requested {
t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err)
}
}
+38
View File
@@ -0,0 +1,38 @@
services:
creator-hub:
build: .
environment:
DOCKER_GATEWAY_URL: http://docker-gateway:8081
ports:
- "127.0.0.1:${CREATORHUB_PORT:-8080}:8080"
read_only: true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
depends_on: [docker-gateway]
networks: [control]
restart: unless-stopped
docker-gateway:
build: .
command: ["/app/docker-gateway"]
environment:
BROWSER_IMAGE: ${BROWSER_IMAGE:-git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215}
BROWSER_NETWORK: creatorhub_browser
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
group_add:
- "${DOCKER_GID:-999}"
read_only: true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
networks: [control]
restart: unless-stopped
networks:
control:
name: creatorhub_control
internal: true
+43
View File
@@ -0,0 +1,43 @@
# 浏览器容器控制面
## 技术选型
- 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。
- 后端:Go 标准库模块化单体。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。
- 数据:本任务不引入业务数据库;Docker 容器标签是运行态事实,Profile 使用命名卷持久化。账号、任务和审计实体仍按产品规划在后续阶段落入 Postgres。
- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。
## 调用链与契约
```text
React ── /api/browsers ──> control-plane ── /v1/browsers ──> docker-gateway ──> docker.sock
└─> browser container
```
`POST /api/browsers` 接受 `{name, seed}` 并创建后启动;`GET /api/browsers` 列出受管容器;`POST /api/browsers/{name}/start|stop` 改变状态;`DELETE /api/browsers/{name}` 仅回收容器,保留 Profile 数据卷。名称唯一约束由 Docker 容器名保证,重复创建返回冲突;启停接受 Docker 的幂等响应,不自动重试未知结果。
## docker.sock 安全边界
将 socket 以只读文件挂载**不会**限制 Docker API 的写操作;拥有 socket 等价于拥有宿主机 root 权限。因此:
- 只有 `docker-gateway` 挂载 socket,控制面和浏览器容器均不可见;网关只加入 control 网络,浏览器不能连接网关;
- 网关只暴露四个面向领域的路由,不提供通用 Docker 代理;
- 镜像、命令、网络、挂载和资源限制均由网关固定,外部输入只有受校验的名称和数字 seed;
- 启停和删除前必须同时匹配固定名称前缀及 `io.creatorhub.managed``io.creatorhub.runtime-id` 标签;
- 动态容器使用只读根文件系统、非 root 镜像、全部 capability drop、`no-new-privileges`、CPU/内存/PID 限制,且无宿主机端口和目录挂载;
- 控制面只绑定 `127.0.0.1`,控制网络为固定名称的 Compose internal 网络;浏览器 bridge 关闭 ICC,阻止运行时直接互访,并按 ownership、role、driver、Internal 和完整 options 失败关闭校验,且拒绝复用 control 网络;
- `BROWSER_IMAGE` 必须是固定 tag,拒绝 `latest`
网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。进入共享或生产环境前,应把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。
## 运行
Docker socket 的 GID 因宿主机而异:
```bash
docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215
DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build
```
打开 <http://127.0.0.1:8080>。默认浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215`,可通过 `BROWSER_IMAGE` 改为其他已审核的固定 tag。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。
+3
View File
@@ -0,0 +1,3 @@
module git.ipao.vip/rogee/creator-hub
go 1.26
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#061a38" />
<title>CreatorHub · 运行环境</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+860
View File
@@ -0,0 +1,860 @@
{
"name": "creator-hub-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "creator-hub-web",
"version": "0.1.0",
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.0",
"vite": "8.2.2"
}
},
"node_modules/@oxc-project/types": {
"version": "0.147.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
"integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
"integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
"integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
"integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
"integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
"integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
"integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
"integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
"integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
"integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
"integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
"integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
"integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
"integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
"integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
"integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.145.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/rolldown": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
"integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.147.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.6",
"@rolldown/binding-android-arm64": "1.2.6",
"@rolldown/binding-darwin-arm64": "1.2.6",
"@rolldown/binding-darwin-x64": "1.2.6",
"@rolldown/binding-freebsd-x64": "1.2.6",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
"@rolldown/binding-linux-arm64-gnu": "1.2.6",
"@rolldown/binding-linux-arm64-musl": "1.2.6",
"@rolldown/binding-linux-ppc64-gnu": "1.2.6",
"@rolldown/binding-linux-s390x-gnu": "1.2.6",
"@rolldown/binding-linux-x64-gnu": "1.2.6",
"@rolldown/binding-linux-x64-musl": "1.2.6",
"@rolldown/binding-openharmony-arm64": "1.2.6",
"@rolldown/binding-win32-arm64-msvc": "1.2.6",
"@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "creator-hub-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.0",
"vite": "8.2.2"
}
}
+149
View File
@@ -0,0 +1,149 @@
import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './styles.css'
const statusLabels = {
created: '已创建',
running: '运行中',
exited: '已停止',
restarting: '重启中',
paused: '已暂停',
}
async function api(path = '', options) {
const response = await fetch(`/api/browsers${path}`, options)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `请求失败 (${response.status})`)
}
return response.status === 204 ? null : response.json()
}
function Icon({ name }) {
const paths = {
runtime: <><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m8 9 3 3-3 3M13 15h3"/></>,
audit: <><path d="M9 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-3"/><rect x="9" y="3" width="6" height="4" rx="1"/></>,
shield: <path d="M12 3 5 6v5c0 4.6 2.9 8.1 7 10 4.1-1.9 7-5.4 7-10V6l-7-3Z"/>,
play: <path d="m9 7 8 5-8 5V7Z"/>,
stop: <rect x="7" y="7" width="10" height="10" rx="1"/>,
trash: <><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></>,
}
return <svg aria-hidden="true" viewBox="0 0 24 24" className="icon">{paths[name]}</svg>
}
function RuntimeRow({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
return (
<tr>
<td data-label="环境"><strong>{runtime.name}</strong><small>{runtime.status}</small></td>
<td data-label="状态"><span className={`state state-${runtime.state}`}>{statusLabels[runtime.state] || runtime.state}</span></td>
<td data-label="Profile"><code>{runtime.profile}</code></td>
<td data-label="CDP"><code>{runtime.endpoint}</code></td>
<td data-label="操作" className="actions">
<button className="button button-outline" disabled={busy || running} onClick={() => onAction(runtime.name, 'start')}><Icon name="play" />启动</button>
<button className="button button-neutral" disabled={busy || !running} onClick={() => onAction(runtime.name, 'stop')}><Icon name="stop" />停止</button>
<button className="button button-danger" disabled={busy} onClick={() => onAction(runtime.name, 'delete')}><Icon name="trash" />回收</button>
</td>
</tr>
)
}
function App() {
const [runtimes, setRuntimes] = useState([])
const [name, setName] = useState('')
const [seed, setSeed] = useState('1000')
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
async function refresh(signal) {
try {
setRuntimes(await api('', { signal }))
setError('')
} catch (reason) {
if (reason.name !== 'AbortError') setError(reason.message)
}
}
useEffect(() => {
const controller = new AbortController()
refresh(controller.signal)
const timer = window.setInterval(() => refresh(controller.signal), 3000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [])
async function createRuntime(event) {
event.preventDefault()
setBusy('create')
setError('')
try {
await api('', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, seed: Number(seed) }),
})
setName('')
await refresh()
} catch (reason) {
setError(reason.message)
} finally {
setBusy('')
}
}
async function act(runtimeName, action) {
if (action === 'delete' && !window.confirm(`回收 ${runtimeName}Profile 数据卷将保留。`)) return
setBusy(runtimeName)
setError('')
try {
await api(`/${runtimeName}${action === 'delete' ? '' : `/${action}`}`, { method: action === 'delete' ? 'DELETE' : 'POST' })
await refresh()
} catch (reason) {
setError(reason.message)
} finally {
setBusy('')
}
}
return (
<div className="shell">
<aside className="sidebar">
<a className="brand" href="/">CreatorHub</a>
<nav aria-label="主导航">
<a className="nav-item active" href="/"><Icon name="runtime" />运行环境</a>
<span className="nav-item disabled"><Icon name="audit" />审计记录</span>
</nav>
</aside>
<main>
<header>
<h1>运行环境</h1>
<p>启动停止并回收隔离的浏览器 Profile</p>
</header>
<form className="create-form" onSubmit={createRuntime}>
<label>环境名称<input required pattern="[a-z0-9](?:[a-z0-9]|-){0,31}" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:account-a" /></label>
<label>Fingerprint Seed<input required min="1" max="2147483647" type="number" value={seed} onChange={(event) => setSeed(event.target.value)} /></label>
<button className="button button-primary" disabled={busy === 'create'}>{busy === 'create' ? '创建中…' : '创建环境'}</button>
</form>
<section className="runtime-list" aria-labelledby="runtime-list-title">
<h2 id="runtime-list-title" className="sr-only">浏览器运行环境</h2>
<table>
<thead><tr><th>环境</th><th>状态</th><th>Profile</th><th>CDP</th><th>操作</th></tr></thead>
<tbody>
{runtimes.map((runtime) => <RuntimeRow key={runtime.id} runtime={runtime} busy={busy === runtime.name} onAction={act} />)}
</tbody>
</table>
{runtimes.length === 0 ? <p className="empty">暂无运行环境创建第一个隔离 Profile</p> : null}
</section>
{error ? <p className="error" role="alert">{error}</p> : null}
<p className="security-note"><Icon name="shield" />Docker socket 仅由受限网关访问</p>
</main>
</div>
)
}
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>)
+93
View File
@@ -0,0 +1,93 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #111827;
background: #ffffff;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input { font: inherit; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; }
.sidebar { padding: 30px 12px; color: #fff; background: #061a38; }
.brand { display: block; margin: 0 12px 38px; color: inherit; font-size: 28px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
.sidebar nav { display: grid; gap: 12px; }
.nav-item { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 0 18px; border-radius: 8px; color: #a9b7cc; font-size: 17px; font-weight: 650; text-decoration: none; }
.nav-item.active { color: #fff; background: #0866ef; }
.nav-item.disabled { opacity: .7; }
.icon { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
main { width: min(100%, 1280px); padding: 44px 36px 72px; }
header { margin-bottom: 30px; }
h1 { margin: 0 0 10px; font-size: 40px; line-height: 1.15; letter-spacing: -1.4px; }
header p { margin: 0; color: #667085; font-size: 18px; }
.create-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 210px; gap: 28px; align-items: end; padding: 30px; border: 1px solid #d7dde7; border-radius: 9px; box-shadow: 0 4px 14px rgb(16 24 40 / 6%); }
label { display: grid; gap: 10px; font-size: 15px; font-weight: 650; }
input { width: 100%; height: 52px; padding: 0 14px; border: 1px solid #c7cfdb; border-radius: 7px; color: #111827; background: #fff; font-size: 16px; }
input::placeholder { color: #929daf; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 44px; padding: 0 14px; border: 1px solid; border-radius: 7px; background: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
.button .icon { width: 17px; height: 17px; }
.button:disabled { cursor: not-allowed; opacity: .4; }
.button-primary { height: 52px; border-color: #0866ef; color: #fff; background: #0866ef; font-size: 16px; }
.button-outline { border-color: #0866ef; color: #0866ef; }
.button-neutral { border-color: #aab4c3; color: #344054; }
.button-danger { border-color: #ef4444; color: #dc2626; }
.runtime-list { margin-top: 42px; overflow: hidden; border: 1px solid #d7dde7; border-radius: 9px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td { padding: 23px 20px; border-bottom: 1px solid #d7dde7; text-align: left; vertical-align: middle; }
th { color: #1f2937; font-size: 14px; font-weight: 700; }
th:nth-child(1) { width: 19%; } th:nth-child(2) { width: 12%; } th:nth-child(3) { width: 17%; } th:nth-child(4) { width: 22%; } th:nth-child(5) { width: 30%; }
tbody tr:last-child td { border-bottom: 0; }
td strong, td small { display: block; }
td small { margin-top: 6px; color: #7a8699; }
code { color: #475467; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; overflow-wrap: anywhere; }
.state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; }
.state::before { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; content: ""; }
.state-running { color: #079455; } .state-running::before { background: #12b76a; }
.state-exited { color: #d97706; } .state-exited::before { background: #f59e0b; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.empty { margin: 0; padding: 52px 20px; color: #667085; text-align: center; }
.error { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 7px; color: #b42318; background: #fef2f2; }
.security-note { display: flex; align-items: center; gap: 12px; margin: 32px 0 0; padding: 19px 24px; border: 1px solid #bfdbfe; border-radius: 8px; color: #243b5a; background: #f8fbff; }
.security-note .icon { color: #164e85; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 1000px) {
.shell { grid-template-columns: 82px 1fr; }
.brand { margin: 4px 8px 38px; font-size: 0; }
.brand::before { font-size: 22px; content: "CH"; }
.nav-item { justify-content: center; padding: 0; font-size: 0; }
.create-form { grid-template-columns: 1fr 1fr; }
.button-primary { grid-column: 1 / -1; }
th:nth-child(4), td:nth-child(4) { display: none; }
th:nth-child(1) { width: 24%; } th:nth-child(2) { width: 18%; } th:nth-child(3) { width: 25%; } th:nth-child(5) { width: 33%; }
}
@media (max-width: 700px) {
.shell { display: block; }
.sidebar { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; }
.brand { margin: 0; font-size: 20px; }
.brand::before { content: none; }
.sidebar nav { display: flex; }
.nav-item { min-height: 44px; padding: 0 14px; font-size: 14px; }
.nav-item.disabled { display: none; }
main { padding: 30px 18px 48px; }
h1 { font-size: 32px; }
.create-form { grid-template-columns: 1fr; padding: 22px; gap: 18px; }
.button-primary { grid-column: auto; }
.runtime-list { overflow: visible; border: 0; }
thead { display: none; }
tbody { display: grid; gap: 14px; }
tr { display: grid; gap: 12px; padding: 20px; border: 1px solid #d7dde7; border-radius: 8px; }
td, tbody tr:last-child td { display: grid; grid-template-columns: 90px 1fr; padding: 0; border: 0; }
td::before { color: #667085; font-size: 13px; content: attr(data-label); }
td:nth-child(4) { display: none; }
.actions { display: flex; padding-top: 8px; }
.actions::before { display: none; }
}
@media (prefers-reduced-motion: no-preference) {
.button { transition: background-color .15s ease, border-color .15s ease, opacity .15s ease; }
.button:not(:disabled):hover { background: #eff6ff; }
.button-primary:not(:disabled):hover { background: #0759d4; }
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://127.0.0.1:8080' },
},
})