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:
loveuer
2025-11-09 22:46:27 +08:00
commit 29088a6b54
45 changed files with 5629 additions and 0 deletions

55
frontend/src/App.tsx Normal file
View 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

View File

15
frontend/src/index.css Normal file
View 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
View 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>,
)

View 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>
)
}

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />