feat: complete OCI registry implementation with docker push/pull support
A lightweight OCI (Open Container Initiative) registry implementation written in Go.
This commit is contained in:
18
frontend/.eslintrc.cjs
Normal file
18
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
77
frontend/README.md
Normal file
77
frontend/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Cluster Frontend
|
||||
|
||||
基于 React + TypeScript + Zustand + MUI 的前端项目。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **React 18** - UI 框架
|
||||
- **TypeScript** - 类型系统
|
||||
- **Vite** - 构建工具
|
||||
- **Zustand** - 状态管理
|
||||
- **Material-UI (MUI)** - UI 组件库
|
||||
|
||||
## 开发
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 或
|
||||
yarn install
|
||||
# 或
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# 或
|
||||
yarn dev
|
||||
# 或
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
开发服务器将在 `http://localhost:3000` 启动。
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# 或
|
||||
yarn build
|
||||
# 或
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
# 或
|
||||
yarn preview
|
||||
# 或
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── components/ # React 组件
|
||||
│ ├── theme.ts # MUI 主题配置
|
||||
│ ├── App.tsx # 主应用组件
|
||||
│ └── main.tsx # 应用入口
|
||||
├── public/ # 静态资源
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.ts # Vite 配置
|
||||
└── tsconfig.json # TypeScript 配置
|
||||
```
|
||||
|
||||
## API 代理
|
||||
|
||||
开发环境已配置 API 代理,所有 `/api/*` 请求会被代理到 `http://localhost:8080`(Go 后端服务)。
|
||||
|
||||
配置位置:`vite.config.ts`
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 霞鹜文楷字体 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.1.0/style.css" />
|
||||
<title>Cluster</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cluster-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^5.16.7",
|
||||
"@mui/material": "^5.16.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
2691
frontend/pnpm-lock.yaml
generated
Normal file
2691
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/src/App.tsx
Normal file
55
frontend/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material'
|
||||
import { Routes, Route, Link } from 'react-router-dom'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import RegistryImageList from './pages/RegistryImageList'
|
||||
|
||||
function App() {
|
||||
const { count, increment, decrement, reset } = useAppStore()
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Cluster
|
||||
</Typography>
|
||||
<Button color="inherit" component={Link} to="/">首页</Button>
|
||||
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Routes>
|
||||
<Route path="/registry/image" element={<RegistryImageList />} />
|
||||
<Route path="/" element={
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
欢迎使用 Cluster
|
||||
</Typography>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zustand 状态管理示例
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="center" sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={decrement}>
|
||||
-
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ minWidth: 60 }}>
|
||||
{count}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={increment}>
|
||||
+
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={reset} sx={{ ml: 2 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
0
frontend/src/components/.gitkeep
Normal file
0
frontend/src/components/.gitkeep
Normal file
15
frontend/src/index.css
Normal file
15
frontend/src/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'LXGW WenKai', '霞鹜文楷', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.tsx'
|
||||
import { theme } from './theme'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
85
frontend/src/pages/RegistryImageList.tsx
Normal file
85
frontend/src/pages/RegistryImageList.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert } from '@mui/material'
|
||||
|
||||
interface RegistryImage {
|
||||
id: number
|
||||
name: string
|
||||
upload_time: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// Format bytes to human readable format
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export default function RegistryImageList() {
|
||||
const [images, setImages] = useState<RegistryImage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false
|
||||
async function fetchImages() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/v1/registry/image/list')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const result = await res.json()
|
||||
// Backend returns: {status, msg, data: {images: [...]}}
|
||||
const list: RegistryImage[] = result.data?.images || []
|
||||
if (!abort) setImages(list)
|
||||
} catch (e: any) {
|
||||
if (!abort) setError(e.message)
|
||||
} finally {
|
||||
if (!abort) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchImages()
|
||||
return () => { abort = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>镜像列表</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Alert severity="error">加载失败: {error}</Alert>}
|
||||
{!loading && !error && (
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>上传时间</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images.map(img => (
|
||||
<TableRow key={img.id} hover>
|
||||
<TableCell>{img.id}</TableCell>
|
||||
<TableCell>{img.name}</TableCell>
|
||||
<TableCell>{img.upload_time}</TableCell>
|
||||
<TableCell>{formatSize(img.size)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{images.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">暂无镜像</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
15
frontend/src/stores/appStore.ts
Normal file
15
frontend/src/stores/appStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AppState {
|
||||
count: number
|
||||
increment: () => void
|
||||
decrement: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
decrement: () => set((state) => ({ count: state.count - 1 })),
|
||||
reset: () => set({ count: 0 }),
|
||||
}))
|
||||
27
frontend/src/theme.ts
Normal file
27
frontend/src/theme.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'"LXGW WenKai"',
|
||||
'"霞鹜文楷"',
|
||||
'Inter',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
},
|
||||
})
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9119',
|
||||
changeOrigin: true,
|
||||
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user