Compare commits

...

2 Commits

8 changed files with 755 additions and 15 deletions

View File

@@ -11,6 +11,7 @@ import "strings"
import "codit/internal/auth" import "codit/internal/auth"
import "codit/internal/config" import "codit/internal/config"
import "codit/internal/db" import "codit/internal/db"
import "codit/internal/docker"
import "codit/internal/git" import "codit/internal/git"
import "codit/internal/handlers" import "codit/internal/handlers"
import httpx "codit/internal/http" import httpx "codit/internal/http"
@@ -246,7 +247,9 @@ func main() {
var repoManager git.RepoManager var repoManager git.RepoManager
repoManager = git.RepoManager{BaseDir: filepath.Join(cfg.DataDir, "git")} repoManager = git.RepoManager{BaseDir: filepath.Join(cfg.DataDir, "git")}
var rpmBase string var rpmBase string
var dockerBase string
rpmBase = filepath.Join(cfg.DataDir, "rpm") rpmBase = filepath.Join(cfg.DataDir, "rpm")
dockerBase = filepath.Join(cfg.DataDir, "docker")
var rpmMeta *rpm.MetaManager var rpmMeta *rpm.MetaManager
rpmMeta = rpm.NewMetaManager() rpmMeta = rpm.NewMetaManager()
var uploadStore storage.FileStore var uploadStore storage.FileStore
@@ -255,6 +258,10 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("rpm dir error: %v", err) log.Fatalf("rpm dir error: %v", err)
} }
err = os.MkdirAll(dockerBase, 0o755)
if err != nil {
log.Fatalf("docker dir error: %v", err)
}
var api *handlers.API var api *handlers.API
api = &handlers.API{ api = &handlers.API{
@@ -263,6 +270,7 @@ func main() {
Repos: repoManager, Repos: repoManager,
RpmBase: rpmBase, RpmBase: rpmBase,
RpmMeta: rpmMeta, RpmMeta: rpmMeta,
DockerBase: dockerBase,
Uploads: uploadStore, Uploads: uploadStore,
} }
@@ -294,6 +302,8 @@ func main() {
} }
var rpmServer http.Handler var rpmServer http.Handler
rpmServer = rpm.NewHTTPServer(rpmBase, authFunc, logger) rpmServer = rpm.NewHTTPServer(rpmBase, authFunc, logger)
var dockerServer http.Handler
dockerServer = docker.NewHTTPServer(store, dockerBase, authFunc, logger)
var router *httpx.Router var router *httpx.Router
router = httpx.NewRouter() router = httpx.NewRouter()
@@ -353,6 +363,11 @@ func main() {
router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile) router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile)
router.Handle("GET", "/api/repos/:id/rpm/tree", api.RepoRPMTree) router.Handle("GET", "/api/repos/:id/rpm/tree", api.RepoRPMTree)
router.Handle("POST", "/api/repos/:id/rpm/upload", api.RepoRPMUpload) router.Handle("POST", "/api/repos/:id/rpm/upload", api.RepoRPMUpload)
router.Handle("GET", "/api/repos/:id/docker/images", api.RepoDockerImages)
router.Handle("GET", "/api/repos/:id/docker/tags", api.RepoDockerTags)
router.Handle("GET", "/api/repos/:id/docker/manifest", api.RepoDockerManifest)
router.Handle("DELETE", "/api/repos/:id/docker/tag", api.RepoDockerDeleteTag)
router.Handle("DELETE", "/api/repos/:id/docker/image", api.RepoDockerDeleteImage)
router.Handle("GET", "/api/projects/:projectId/issues", api.ListIssues) router.Handle("GET", "/api/projects/:projectId/issues", api.ListIssues)
router.Handle("POST", "/api/projects/:projectId/issues", api.CreateIssue) router.Handle("POST", "/api/projects/:projectId/issues", api.CreateIssue)
@@ -377,6 +392,8 @@ func main() {
mux.Handle(cfg.RPMHTTPPrefix+"/", http.StripPrefix(cfg.RPMHTTPPrefix, rpmRewrite)) mux.Handle(cfg.RPMHTTPPrefix+"/", http.StripPrefix(cfg.RPMHTTPPrefix, rpmRewrite))
rpmIDRewrite = &rpmIDPathRewriteHandler{next: rpmServer, store: store} rpmIDRewrite = &rpmIDPathRewriteHandler{next: rpmServer, store: store}
mux.Handle(cfg.RPMHTTPPrefix+"-id/", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite)) mux.Handle(cfg.RPMHTTPPrefix+"-id/", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite))
mux.Handle("/v2", dockerServer)
mux.Handle("/v2/", dockerServer)
mux.Handle("/api/graphql", middleware.WithUser(store, middleware.RequireAuth(graphqlHandler))) mux.Handle("/api/graphql", middleware.WithUser(store, middleware.RequireAuth(graphqlHandler)))
mux.Handle("/api/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router)))) mux.Handle("/api/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router))))
mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, router))) mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, router)))

View File

@@ -14,6 +14,7 @@ import "unicode"
import "codit/internal/auth" import "codit/internal/auth"
import "codit/internal/config" import "codit/internal/config"
import "codit/internal/db" import "codit/internal/db"
import "codit/internal/docker"
import "codit/internal/git" import "codit/internal/git"
import "codit/internal/middleware" import "codit/internal/middleware"
import "codit/internal/models" import "codit/internal/models"
@@ -27,6 +28,7 @@ type API struct {
Repos git.RepoManager Repos git.RepoManager
RpmBase string RpmBase string
RpmMeta *rpm.MetaManager RpmMeta *rpm.MetaManager
DockerBase string
Uploads storage.FileStore Uploads storage.FileStore
} }
@@ -66,12 +68,14 @@ type repoResponse struct {
models.Repo models.Repo
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
RPMURL string `json:"rpm_url"` RPMURL string `json:"rpm_url"`
DockerURL string `json:"docker_url"`
} }
type repoListItem struct { type repoListItem struct {
models.Repo models.Repo
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
RPMURL string `json:"rpm_url"` RPMURL string `json:"rpm_url"`
DockerURL string `json:"docker_url"`
OwnerProject string `json:"owner_project"` OwnerProject string `json:"owner_project"`
OwnerSlug string `json:"owner_slug"` OwnerSlug string `json:"owner_slug"`
} }
@@ -646,16 +650,21 @@ func (api *API) ListRepos(w http.ResponseWriter, r *http.Request, params map[str
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if repo.Type == "git" { if repo.Type == "git" {
cloneURL = api.cloneURL(owner.Slug, repo.Name) cloneURL = api.cloneURL(owner.Slug, repo.Name)
} }
if repo.Type == "rpm" { if repo.Type == "rpm" {
rpmURL = api.rpmURL(owner.Slug, repo.Name) rpmURL = api.rpmURL(owner.Slug, repo.Name)
} }
if repo.Type == "docker" {
dockerURL = api.dockerURL(owner.Slug, repo.Name)
}
resp = append(resp, repoListItem{ resp = append(resp, repoListItem{
Repo: repo, Repo: repo,
CloneURL: cloneURL, CloneURL: cloneURL,
RPMURL: rpmURL, RPMURL: rpmURL,
DockerURL: dockerURL,
OwnerProject: owner.ID, OwnerProject: owner.ID,
OwnerSlug: owner.Slug, OwnerSlug: owner.Slug,
}) })
@@ -728,16 +737,21 @@ func (api *API) ListAllRepos(w http.ResponseWriter, r *http.Request, _ map[strin
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if repo.Type == "git" { if repo.Type == "git" {
cloneURL = api.cloneURL(owner.Slug, repo.Name) cloneURL = api.cloneURL(owner.Slug, repo.Name)
} }
if repo.Type == "rpm" { if repo.Type == "rpm" {
rpmURL = api.rpmURL(owner.Slug, repo.Name) rpmURL = api.rpmURL(owner.Slug, repo.Name)
} }
if repo.Type == "docker" {
dockerURL = api.dockerURL(owner.Slug, repo.Name)
}
resp = append(resp, repoListItem{ resp = append(resp, repoListItem{
Repo: repo, Repo: repo,
CloneURL: cloneURL, CloneURL: cloneURL,
RPMURL: rpmURL, RPMURL: rpmURL,
DockerURL: dockerURL,
OwnerProject: owner.ID, OwnerProject: owner.ID,
OwnerSlug: owner.Slug, OwnerSlug: owner.Slug,
}) })
@@ -764,13 +778,17 @@ func (api *API) GetRepo(w http.ResponseWriter, r *http.Request, params map[strin
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if repo.Type == "git" { if repo.Type == "git" {
cloneURL = api.cloneURL(project.Slug, repo.Name) cloneURL = api.cloneURL(project.Slug, repo.Name)
} }
if repo.Type == "rpm" { if repo.Type == "rpm" {
rpmURL = api.rpmURL(project.Slug, repo.Name) rpmURL = api.rpmURL(project.Slug, repo.Name)
} }
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL}) if repo.Type == "docker" {
dockerURL = api.dockerURL(project.Slug, repo.Name)
}
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL})
} }
func (api *API) CreateRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) CreateRepo(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -833,13 +851,20 @@ func (api *API) CreateRepo(w http.ResponseWriter, r *http.Request, params map[st
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
} else { } else if repoType == "rpm" {
repoPath = filepath.Join(api.RpmBase, project.ID, req.Name) repoPath = filepath.Join(api.RpmBase, project.ID, req.Name)
err = os.MkdirAll(repoPath, 0o755) err = os.MkdirAll(repoPath, 0o755)
if err != nil { if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
} else if repoType == "docker" {
repoPath = filepath.Join(api.DockerBase, project.ID, req.Name)
err = os.MkdirAll(repoPath, 0o755)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
} }
repo = models.Repo{ repo = models.Repo{
ID: repoID, ID: repoID,
@@ -857,13 +882,17 @@ func (api *API) CreateRepo(w http.ResponseWriter, r *http.Request, params map[st
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if created.Type == "git" { if created.Type == "git" {
cloneURL = api.cloneURL(project.Slug, created.Name) cloneURL = api.cloneURL(project.Slug, created.Name)
} }
if created.Type == "rpm" { if created.Type == "rpm" {
rpmURL = api.rpmURL(project.Slug, created.Name) rpmURL = api.rpmURL(project.Slug, created.Name)
} }
WriteJSON(w, http.StatusCreated, repoResponse{Repo: created, CloneURL: cloneURL, RPMURL: rpmURL}) if created.Type == "docker" {
dockerURL = api.dockerURL(project.Slug, created.Name)
}
WriteJSON(w, http.StatusCreated, repoResponse{Repo: created, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL})
} }
func (api *API) ListAvailableRepos(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) ListAvailableRepos(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -963,9 +992,23 @@ func (api *API) AttachForeignRepo(w http.ResponseWriter, r *http.Request, params
return return
} }
repo.IsForeign = true repo.IsForeign = true
var cloneURL string
var rpmURL string
var dockerURL string
if repo.Type == "git" {
cloneURL = api.cloneURL(project.Slug, repo.Name)
}
if repo.Type == "rpm" {
rpmURL = api.rpmURL(project.Slug, repo.Name)
}
if repo.Type == "docker" {
dockerURL = api.dockerURL(project.Slug, repo.Name)
}
WriteJSON(w, http.StatusCreated, repoListItem{ WriteJSON(w, http.StatusCreated, repoListItem{
Repo: repo, Repo: repo,
CloneURL: api.cloneURL(project.Slug, repo.Name), CloneURL: cloneURL,
RPMURL: rpmURL,
DockerURL: dockerURL,
OwnerProject: project.ID, OwnerProject: project.ID,
OwnerSlug: project.Slug, OwnerSlug: project.Slug,
}) })
@@ -1031,8 +1074,10 @@ func (api *API) UpdateRepo(w http.ResponseWriter, r *http.Request, params map[st
} }
if repo.Type == "git" { if repo.Type == "git" {
newPath = api.Repos.RepoPathFor(project.ID, req.Name) newPath = api.Repos.RepoPathFor(project.ID, req.Name)
} else { } else if repo.Type == "rpm" {
newPath = filepath.Join(api.RpmBase, project.ID, req.Name) newPath = filepath.Join(api.RpmBase, project.ID, req.Name)
} else if repo.Type == "docker" {
newPath = filepath.Join(api.DockerBase, project.ID, req.Name)
} }
if repo.Path != newPath { if repo.Path != newPath {
oldPath = repo.Path oldPath = repo.Path
@@ -1069,13 +1114,17 @@ func (api *API) UpdateRepo(w http.ResponseWriter, r *http.Request, params map[st
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if repo.Type == "git" { if repo.Type == "git" {
cloneURL = api.cloneURL(project.Slug, repo.Name) cloneURL = api.cloneURL(project.Slug, repo.Name)
} }
if repo.Type == "rpm" { if repo.Type == "rpm" {
rpmURL = api.rpmURL(project.Slug, repo.Name) rpmURL = api.rpmURL(project.Slug, repo.Name)
} }
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL}) if repo.Type == "docker" {
dockerURL = api.dockerURL(project.Slug, repo.Name)
}
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL})
} }
func (api *API) DeleteRepo(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) DeleteRepo(w http.ResponseWriter, r *http.Request, params map[string]string) {
@@ -1116,13 +1165,17 @@ func (api *API) DeleteRepo(w http.ResponseWriter, r *http.Request, params map[st
} }
var cloneURL string var cloneURL string
var rpmURL string var rpmURL string
var dockerURL string
if repo.Type == "git" { if repo.Type == "git" {
cloneURL = api.cloneURL(project.Slug, repo.Name) cloneURL = api.cloneURL(project.Slug, repo.Name)
} }
if repo.Type == "rpm" { if repo.Type == "rpm" {
rpmURL = api.rpmURL(project.Slug, repo.Name) rpmURL = api.rpmURL(project.Slug, repo.Name)
} }
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL}) if repo.Type == "docker" {
dockerURL = api.dockerURL(project.Slug, repo.Name)
}
WriteJSON(w, http.StatusOK, repoResponse{Repo: repo, CloneURL: cloneURL, RPMURL: rpmURL, DockerURL: dockerURL})
} }
func moveToTrash(path string) (string, error) { func moveToTrash(path string) (string, error) {
@@ -1774,6 +1827,7 @@ func (api *API) RepoTypes(w http.ResponseWriter, r *http.Request, _ map[string]s
types = []repoTypeItem{ types = []repoTypeItem{
{Value: "git", Label: "Git"}, {Value: "git", Label: "Git"},
{Value: "rpm", Label: "RPM"}, {Value: "rpm", Label: "RPM"},
{Value: "docker", Label: "Docker"},
} }
WriteJSON(w, http.StatusOK, types) WriteJSON(w, http.StatusOK, types)
} }
@@ -2357,6 +2411,154 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size}) WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size})
} }
func (api *API) RepoDockerTags(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var tags []docker.TagInfo
var image string
var imagePath string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "viewer") {
return
}
if repo.Type != "docker" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
return
}
image = strings.TrimSpace(r.URL.Query().Get("image"))
imagePath = docker.ImagePath(repo.Path, image)
tags, err = docker.ListTags(imagePath)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, tags)
}
func (api *API) RepoDockerManifest(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var ref string
var detail docker.ManifestDetail
var image string
var imagePath string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "viewer") {
return
}
if repo.Type != "docker" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
return
}
ref = strings.TrimSpace(r.URL.Query().Get("ref"))
if ref == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "ref required"})
return
}
image = strings.TrimSpace(r.URL.Query().Get("image"))
imagePath = docker.ImagePath(repo.Path, image)
detail, err = docker.GetManifestDetail(imagePath, ref)
if err != nil {
if err == docker.ErrNotFound {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "manifest not found"})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, detail)
}
func (api *API) RepoDockerImages(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var images []string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "viewer") {
return
}
if repo.Type != "docker" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
return
}
images, err = docker.ListImages(repo.Path)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, images)
}
func (api *API) RepoDockerDeleteTag(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var image string
var tag string
var imagePath string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "writer") {
return
}
if repo.Type != "docker" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
return
}
image = strings.TrimSpace(r.URL.Query().Get("image"))
tag = strings.TrimSpace(r.URL.Query().Get("tag"))
if tag == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "tag required"})
return
}
imagePath = docker.ImagePath(repo.Path, image)
err = docker.DeleteTag(imagePath, tag)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (api *API) RepoDockerDeleteImage(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var image string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "writer") {
return
}
if repo.Type != "docker" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not docker"})
return
}
image = strings.TrimSpace(r.URL.Query().Get("image"))
err = docker.DeleteImage(repo.Path, image)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (api *API) ListIssues(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) ListIssues(w http.ResponseWriter, r *http.Request, params map[string]string) {
var issues []models.Issue var issues []models.Issue
var err error var err error
@@ -2737,7 +2939,7 @@ func normalizeRepoType(value string) (string, bool) {
if v == "" { if v == "" {
return "git", true return "git", true
} }
if v == "git" || v == "rpm" { if v == "git" || v == "rpm" || v == "docker" {
return v, true return v, true
} }
return "", false return "", false
@@ -2916,3 +3118,9 @@ func (api *API) rpmURL(projectSlug, repoName string) string {
} }
return base + prefix + "/" + projectSlug + "/" + repoName return base + prefix + "/" + projectSlug + "/" + repoName
} }
func (api *API) dockerURL(projectSlug, repoName string) string {
var base string
base = strings.TrimRight(api.Cfg.PublicBaseURL, "/")
return base + "/v2/" + projectSlug + "/" + repoName
}

View File

@@ -23,9 +23,10 @@ export interface Repo {
id: string id: string
project_id: string project_id: string
name: string name: string
type?: 'git' | 'rpm' type?: 'git' | 'rpm' | 'docker'
clone_url?: string clone_url?: string
rpm_url?: string rpm_url?: string
docker_url?: string
is_foreign?: boolean is_foreign?: boolean
owner_project?: string owner_project?: string
owner_slug?: string owner_slug?: string
@@ -64,6 +65,30 @@ export interface RpmPackageDetail extends RpmPackageSummary {
provides: string[] provides: string[]
} }
export interface DockerTagInfo {
tag: string
digest: string
size: number
media_type: string
}
export interface DockerManifestDetail {
reference: string
digest: string
media_type: string
size: number
config: {
created?: string
architecture?: string
os?: string
}
layers: {
mediaType: string
digest: string
size: number
}[]
}
export interface RpmTreeEntry { export interface RpmTreeEntry {
name: string name: string
path: string path: string
@@ -375,6 +400,29 @@ export const api = {
params.set('path', path) params.set('path', path)
return requestBinary(`/api/repos/${repoId}/rpm/file?${params.toString()}`) return requestBinary(`/api/repos/${repoId}/rpm/file?${params.toString()}`)
}, },
listDockerImages: (repoId: string) => request<string[]>(`/api/repos/${repoId}/docker/images`),
listDockerTags: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<DockerTagInfo[]>(`/api/repos/${repoId}/docker/tags?${params.toString()}`)
},
getDockerManifest: (repoId: string, ref: string, image: string) => {
const params = new URLSearchParams()
params.set('ref', ref)
if (image) params.set('image', image)
return request<DockerManifestDetail>(`/api/repos/${repoId}/docker/manifest?${params.toString()}`)
},
deleteDockerTag: (repoId: string, image: string, tag: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
params.set('tag', tag)
return request<{ status: string }>(`/api/repos/${repoId}/docker/tag?${params.toString()}`, { method: 'DELETE' })
},
deleteDockerImage: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<{ status: string }>(`/api/repos/${repoId}/docker/image?${params.toString()}`, { method: 'DELETE' })
},
listIssues: (projectId: string) => request<Issue[]>(`/api/projects/${projectId}/issues`), listIssues: (projectId: string) => request<Issue[]>(`/api/projects/${projectId}/issues`),
createIssue: (projectId: string, payload: { title: string; body: string }) => createIssue: (projectId: string, payload: { title: string; body: string }) =>

View File

@@ -22,6 +22,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
const isCommits = currentPath.startsWith(`${basePath}/commits`) const isCommits = currentPath.startsWith(`${basePath}/commits`)
const isBranches = currentPath.startsWith(`${basePath}/branches`) const isBranches = currentPath.startsWith(`${basePath}/branches`)
const isRPM = repoType === 'rpm' const isRPM = repoType === 'rpm'
const isDocker = repoType === 'docker'
return ( return (
<Paper <Paper
@@ -35,9 +36,9 @@ export default function RepoSubNav(props: RepoSubNavProps) {
> >
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<Button component={Link} to={codePath} variant={isCode ? 'contained' : 'outlined'} size="small"> <Button component={Link} to={codePath} variant={isCode ? 'contained' : 'outlined'} size="small">
{isRPM ? 'Packages' : 'Code'} {isRPM ? 'Packages' : isDocker ? 'Images' : 'Code'}
</Button> </Button>
{!isRPM ? ( {!isRPM && !isDocker ? (
<> <>
<Button component={Link} to={commitsPath} variant={isCommits ? 'contained' : 'outlined'} size="small"> <Button component={Link} to={commitsPath} variant={isCommits ? 'contained' : 'outlined'} size="small">
Commits Commits

View File

@@ -9,7 +9,7 @@ export default function GlobalReposPage() {
const [reposError, setReposError] = useState<string | null>(null) const [reposError, setReposError] = useState<string | null>(null)
const [reposLoading, setReposLoading] = useState(false) const [reposLoading, setReposLoading] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | 'git' | 'rpm'>('all') const [typeFilter, setTypeFilter] = useState<'all' | 'git' | 'rpm' | 'docker'>('all')
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [pageSizeInput, setPageSizeInput] = useState('20') const [pageSizeInput, setPageSizeInput] = useState('20')
@@ -82,7 +82,7 @@ export default function GlobalReposPage() {
size="small" size="small"
value={typeFilter} value={typeFilter}
onChange={(event) => { onChange={(event) => {
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm') setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker')
setPage(0) setPage(0)
}} }}
sx={{ minWidth: 120 }} sx={{ minWidth: 120 }}
@@ -90,6 +90,7 @@ export default function GlobalReposPage() {
<MenuItem value="all">All</MenuItem> <MenuItem value="all">All</MenuItem>
<MenuItem value="git">Git</MenuItem> <MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem> <MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField> </TextField>
<TextField <TextField
label="Items" label="Items"

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api, Repo } from '../api' import { api, Repo } from '../api'
import RepoGitDetailPage from './RepoGitDetailPage' import RepoGitDetailPage from './RepoGitDetailPage'
import RepoDockerDetailPage from './RepoDockerDetailPage'
import RepoRpmDetailPage from './RepoRpmDetailPage' import RepoRpmDetailPage from './RepoRpmDetailPage'
export default function RepoDetailPage() { export default function RepoDetailPage() {
@@ -69,5 +70,8 @@ export default function RepoDetailPage() {
if (repo.type === 'rpm') { if (repo.type === 'rpm') {
return <RepoRpmDetailPage initialRepo={repo} /> return <RepoRpmDetailPage initialRepo={repo} />
} }
if (repo.type === 'docker') {
return <RepoDockerDetailPage initialRepo={repo} />
}
return <RepoGitDetailPage initialRepo={repo} /> return <RepoGitDetailPage initialRepo={repo} />
} }

View File

@@ -0,0 +1,460 @@
import {
Alert,
Box,
Button,
Divider,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemButton,
ListItemText,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
type RepoDockerDetailPageProps = {
initialRepo?: Repo
}
export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const { initialRepo } = props
const { projectId, repoId } = useParams()
const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [project, setProject] = useState<Project | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [imagesError, setImagesError] = useState<string | null>(null)
const [selectedImage, setSelectedImage] = useState('')
const [tags, setTags] = useState<DockerTagInfo[]>([])
const [tagsError, setTagsError] = useState<string | null>(null)
const [selectedTag, setSelectedTag] = useState<DockerTagInfo | null>(null)
const [detail, setDetail] = useState<DockerManifestDetail | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTagOpen, setDeleteTagOpen] = useState(false)
const [deleteTagName, setDeleteTagName] = useState('')
const [deleteTagConfirm, setDeleteTagConfirm] = useState('')
const [deleteImageOpen, setDeleteImageOpen] = useState(false)
const [deleteImagePath, setDeleteImagePath] = useState('')
const [deleteImageLabel, setDeleteImageLabel] = useState('')
const [deleteImageConfirm, setDeleteImageConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const initRepoRef = useRef<string | null>(null)
const initProjectRef = useRef<string | null>(null)
useEffect(() => {
if (!repoId) return
if (initRepoRef.current === repoId) return
initRepoRef.current = repoId
setLoadError(null)
if (initialRepo && initialRepo.id === repoId) {
setRepo(initialRepo)
return
}
api.getRepo(repoId).then((data) => setRepo(data)).catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load repository'
setLoadError(message)
setRepo(null)
})
}, [repoId, initialRepo])
useEffect(() => {
if (!projectId) return
if (initProjectRef.current === projectId) return
initProjectRef.current = projectId
api
.getProject(projectId)
.then((data) => setProject(data))
.catch(() => setProject(null))
}, [projectId])
useEffect(() => {
if (!repoId) return
if (!repo || repo.type !== 'docker') {
setImages([])
setImagesError(null)
setSelectedImage('')
return
}
api
.listDockerImages(repoId)
.then((data) => {
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !selectedImage) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
}, [repoId, repo])
useEffect(() => {
if (!repoId) return
if (!repo || repo.type !== 'docker') {
setTags([])
setTagsError(null)
return
}
api
.listDockerTags(repoId, selectedImage)
.then((data) => {
setTags(Array.isArray(data) ? data : [])
setTagsError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load tags'
setTagsError(message)
setTags([])
})
}, [repoId, repo, selectedImage])
const handleTagSelect = (tag: DockerTagInfo) => {
if (!repoId) return
setSelectedTag(tag)
setDetail(null)
setDetailError(null)
setDetailLoading(true)
api
.getDockerManifest(repoId, tag.tag, selectedImage)
.then((data) => {
setDetail(data)
setDetailError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load manifest'
setDetailError(message)
})
.finally(() => {
setDetailLoading(false)
})
}
const refreshImages = () => {
if (!repoId) return
api
.listDockerImages(repoId)
.then((data) => {
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !list.includes(selectedImage)) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
}
const refreshTags = () => {
if (!repoId) return
api
.listDockerTags(repoId, selectedImage)
.then((data) => {
setTags(Array.isArray(data) ? data : [])
setTagsError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load tags'
setTagsError(message)
setTags([])
})
}
const handleDeleteTag = async () => {
if (!repoId || !deleteTagName) return
if (deleteTagConfirm.trim() !== deleteTagName) {
setDeleteError('Type the tag name to confirm deletion.')
return
}
setDeleteError(null)
setDeleting(true)
try {
await api.deleteDockerTag(repoId, selectedImage, deleteTagName)
setDeleteTagOpen(false)
setDeleteTagConfirm('')
setDeleteTagName('')
refreshTags()
setDetail(null)
setSelectedTag(null)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete tag'
setDeleteError(message)
} finally {
setDeleting(false)
}
}
const handleDeleteImage = async () => {
if (!repoId) return
if (deleteImageConfirm.trim() !== deleteImageLabel) {
setDeleteError('Type the image name to confirm deletion.')
return
}
setDeleteError(null)
setDeleting(true)
try {
await api.deleteDockerImage(repoId, deleteImagePath)
setDeleteImageOpen(false)
setDeleteImageConfirm('')
setDeleteImagePath('')
setDeleteImageLabel('')
setSelectedImage('')
setSelectedTag(null)
setDetail(null)
refreshImages()
refreshTags()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete image'
setDeleteError(message)
} finally {
setDeleting(false)
}
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button component={Link} to="/projects" size="small">
Projects
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
{project ? (
<>
<Button component={Link} to={`/projects/${projectId}`} size="small">
{project.name}
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
</>
) : null}
<Button component={Link} to={`/projects/${projectId}/repos`} size="small">
Repositories
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{repo?.name || 'Repository'}
</Typography>
</Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box>
{projectId && repoId ? <RepoSubNav projectId={projectId} repoId={repoId} repoType={repo?.type} /> : null}
<Divider sx={{ mb: 1 }} />
{loadError ? (
<Typography variant="body2" color="error" sx={{ mb: 1 }}>
{loadError}
</Typography>
) : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}>
<Paper sx={{ p: 2 }}>
{imagesError ? (
<Alert severity="warning" sx={{ mb: 1 }}>
{imagesError}
</Alert>
) : null}
<List dense>
{images.map((image) => (
<ListItem key={image || 'root'} disablePadding>
<ListItemButton
onClick={() => {
setSelectedImage(image)
setSelectedTag(null)
setDetail(null)
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 600 : 400 }}>
{image || '(root)'}
</Typography>
}
/>
</ListItemButton>
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
setDeleteError(null)
setDeleteImagePath(image)
setDeleteImageLabel(image || '(root)')
setDeleteImageConfirm('')
setDeleteImageOpen(true)
}}
aria-label={`Delete image ${image || '(root)'}`}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</ListItem>
))}
{!images.length && !imagesError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
No images found.
</Typography>
) : null}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
{tagsError ? (
<Alert severity="warning" sx={{ mb: 1 }}>
{tagsError}
</Alert>
) : null}
{selectedImage !== '' || images.includes('') ? (
<Box sx={{ mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Image: {selectedImage || '(root)'}
</Typography>
</Box>
) : null}
<List dense>
{tags.map((tag) => (
<ListItem key={tag.tag} disablePadding>
<ListItemButton onClick={() => handleTagSelect(tag)}>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 600 : 400 }}>
{tag.tag}
</Typography>
}
secondary={tag.digest ? tag.digest.slice(0, 12) : ''}
/>
</ListItemButton>
<IconButton
size="small"
onClick={() => {
setDeleteError(null)
setDeleteTagName(tag.tag)
setDeleteTagConfirm('')
setDeleteTagOpen(true)
}}
aria-label={`Delete tag ${tag.tag}`}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</ListItem>
))}
{!tags.length && !tagsError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
No tags found.
</Typography>
) : null}
</List>
{detailLoading ? (
<Typography variant="body2" color="text.secondary">
Loading manifest...
</Typography>
) : null}
{detailError ? (
<Alert severity="warning">{detailError}</Alert>
) : null}
{detail ? (
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="body2">Tag: {detail.reference}</Typography>
<Typography variant="body2">Digest: {detail.digest}</Typography>
<Typography variant="body2">Media: {detail.media_type}</Typography>
<Typography variant="body2">Size: {detail.size} bytes</Typography>
<Divider sx={{ my: 0.5 }} />
<Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography>
<Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography>
<Typography variant="body2">
Created: {detail.config?.created ? new Date(detail.config.created).toLocaleString() : 'n/a'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography>
<List dense>
{detail.layers?.map((layer) => (
<ListItem key={layer.digest}>
<ListItemText primary={layer.digest} secondary={`${layer.size} bytes`} />
</ListItem>
))}
</List>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Select a tag to view details.
</Typography>
)}
</Paper>
</Box>
<Dialog open={deleteTagOpen} onClose={() => setDeleteTagOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete tag</DialogTitle>
<DialogContent>
{deleteError ? <Alert severity="error">{deleteError}</Alert> : null}
<Typography variant="body2" sx={{ mt: 1 }}>
Delete tag "{deleteTagName}"?
</Typography>
<TextField
label="Type tag to confirm"
value={deleteTagConfirm}
onChange={(event) => setDeleteTagConfirm(event.target.value)}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTagOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteTag}
variant="contained"
color="error"
disabled={deleting || deleteTagConfirm.trim() !== deleteTagName}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={deleteImageOpen} onClose={() => setDeleteImageOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete image</DialogTitle>
<DialogContent>
{deleteError ? <Alert severity="error">{deleteError}</Alert> : null}
<Typography variant="body2" sx={{ mt: 1 }}>
Delete image "{deleteImageLabel || '(root)'}" and all tags?
</Typography>
<TextField
label="Type image name to confirm"
value={deleteImageConfirm}
onChange={(event) => setDeleteImageConfirm(event.target.value)}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteImageOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteImage}
variant="contained"
color="error"
disabled={deleting || deleteImageConfirm.trim() !== deleteImageLabel}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -463,7 +463,7 @@ export default function ReposPage() {
<Chip label={repo.type || 'git'} size="small" variant="outlined" /> <Chip label={repo.type || 'git'} size="small" variant="outlined" />
{repo.is_foreign ? <Chip label="foreign" size="small" color="warning" variant="outlined" /> : null} {repo.is_foreign ? <Chip label="foreign" size="small" color="warning" variant="outlined" /> : null}
{repo.is_foreign && repo.owner_slug ? <Chip label={repo.owner_slug} size="small" variant="outlined" /> : null} {repo.is_foreign && repo.owner_slug ? <Chip label={repo.owner_slug} size="small" variant="outlined" /> : null}
{(repo.type === 'rpm' ? repo.rpm_url : repo.clone_url) ? ( {(repo.type === 'rpm' ? repo.rpm_url : repo.type === 'docker' ? repo.docker_url : repo.clone_url) ? (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
@@ -476,7 +476,7 @@ export default function ReposPage() {
textOverflow: 'ellipsis' textOverflow: 'ellipsis'
}} }}
> >
{repo.type === 'rpm' ? repo.rpm_url : repo.clone_url} {repo.type === 'rpm' ? repo.rpm_url : repo.type === 'docker' ? repo.docker_url : repo.clone_url}
</Typography> </Typography>
) : null} ) : null}
</Box> </Box>
@@ -530,6 +530,7 @@ export default function ReposPage() {
> >
<MenuItem value="git">Git</MenuItem> <MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem> <MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField> </TextField>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button> <Button onClick={() => setCreateOpen(false)}>Cancel</Button>