|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
|
import {
|
|
|
|
|
Box,
|
|
|
|
|
Chip,
|
|
|
|
|
Divider,
|
|
|
|
|
IconButton,
|
|
|
|
|
List,
|
|
|
|
@@ -11,10 +12,11 @@ import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
Typography
|
|
|
|
|
} from '@mui/material'
|
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { useParams } from 'react-router-dom'
|
|
|
|
|
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
|
|
|
|
import { api, DockerManifestDetail, DockerPlatformInfo, DockerTagInfo, Project, Repo } from '../api'
|
|
|
|
|
import { formatCommitTime } from '../time'
|
|
|
|
|
import { formatBytes } from '../components/cardDetailUtils'
|
|
|
|
|
import { copyText } from '../clipboard'
|
|
|
|
|
import ContextToolbar from '../components/ContextToolbar'
|
|
|
|
|
import ProjectPageFrame from '../components/ProjectPageFrame'
|
|
|
|
@@ -28,13 +30,62 @@ import CodeIcon from '@mui/icons-material/Code'
|
|
|
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
|
|
|
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
|
|
|
|
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
|
|
|
|
import FolderIcon from '@mui/icons-material/Folder'
|
|
|
|
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
|
|
|
|
import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined'
|
|
|
|
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
|
|
|
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
|
|
|
|
|
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
|
|
|
|
import LazyCodeBlock from '../components/LazyCodeBlock'
|
|
|
|
|
import DockerRenameDialog from '../components/DockerRenameDialog'
|
|
|
|
|
|
|
|
|
|
type RepoDockerDetailPageProps = {
|
|
|
|
|
initialRepo?: Repo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DockerFolder = { name: string; path: string; isImage: boolean; hasChildren: boolean }
|
|
|
|
|
|
|
|
|
|
// Synthesize the immediate child namespaces of `path` from the flat list of
|
|
|
|
|
// image names (which may contain '/'). A child is an image when an image name
|
|
|
|
|
// matches its path exactly, and a namespace when other images sit beneath it;
|
|
|
|
|
// it can be both.
|
|
|
|
|
function childFolders(images: string[], path: string): DockerFolder[] {
|
|
|
|
|
const prefix = path ? path + '/' : ''
|
|
|
|
|
const map = new Map<string, { isImage: boolean; hasChildren: boolean }>()
|
|
|
|
|
let img: string
|
|
|
|
|
let rest: string
|
|
|
|
|
let parts: string[]
|
|
|
|
|
let seg: string
|
|
|
|
|
let childPath: string
|
|
|
|
|
let entry: { isImage: boolean; hasChildren: boolean } | undefined
|
|
|
|
|
for (img of images) {
|
|
|
|
|
if (!img) continue
|
|
|
|
|
if (path && img === path) continue
|
|
|
|
|
if (prefix && !img.startsWith(prefix)) continue
|
|
|
|
|
rest = prefix ? img.slice(prefix.length) : img
|
|
|
|
|
if (!rest) continue
|
|
|
|
|
parts = rest.split('/')
|
|
|
|
|
seg = parts[0]
|
|
|
|
|
childPath = prefix + seg
|
|
|
|
|
entry = map.get(seg) || { isImage: false, hasChildren: false }
|
|
|
|
|
if (img === childPath) entry.isImage = true
|
|
|
|
|
if (parts.length > 1) entry.hasChildren = true
|
|
|
|
|
map.set(seg, entry)
|
|
|
|
|
}
|
|
|
|
|
return Array.from(map.entries())
|
|
|
|
|
.map(([name, e]) => ({ name, path: prefix + name, isImage: e.isImage, hasChildren: e.hasChildren }))
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pathIsImage(images: string[], path: string): boolean {
|
|
|
|
|
return path !== '' ? images.includes(path) : images.includes('')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parentPath(path: string): string {
|
|
|
|
|
const idx = path.lastIndexOf('/')
|
|
|
|
|
return idx === -1 ? '' : path.slice(0, idx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
const { initialRepo } = props
|
|
|
|
|
const { projectId, repoId } = useParams()
|
|
|
|
@@ -44,13 +95,14 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
|
|
|
const [images, setImages] = useState<string[]>([])
|
|
|
|
|
const [imagesError, setImagesError] = useState<string | null>(null)
|
|
|
|
|
const [selectedImage, setSelectedImage] = useState('')
|
|
|
|
|
const [path, setPath] = 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 [showHistory, setShowHistory] = useState(false)
|
|
|
|
|
const [deleteTagOpen, setDeleteTagOpen] = useState(false)
|
|
|
|
|
const [deleteTagName, setDeleteTagName] = useState('')
|
|
|
|
|
const [deleteTagConfirm, setDeleteTagConfirm] = useState('')
|
|
|
|
@@ -74,6 +126,9 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
const initProjectRef = useRef<string | null>(null)
|
|
|
|
|
const canWrite = repoEdit.canWrite
|
|
|
|
|
|
|
|
|
|
const folders = childFolders(images, path)
|
|
|
|
|
const currentIsImage = pathIsImage(images, path)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
if (initRepoRef.current === repoId) return
|
|
|
|
@@ -84,8 +139,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
api.getRepo(repoId).then((data) => setRepo(data)).catch((err) => {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to load repository'
|
|
|
|
|
setLoadError(message)
|
|
|
|
|
setLoadError(err instanceof Error ? err.message : 'Failed to load repository')
|
|
|
|
|
setRepo(null)
|
|
|
|
|
})
|
|
|
|
|
}, [repoId, initialRepo])
|
|
|
|
@@ -94,183 +148,110 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
if (!projectId) return
|
|
|
|
|
if (initProjectRef.current === projectId) return
|
|
|
|
|
initProjectRef.current = projectId
|
|
|
|
|
api
|
|
|
|
|
.getProject(projectId)
|
|
|
|
|
.then((data) => setProject(data))
|
|
|
|
|
.catch(() => setProject(null))
|
|
|
|
|
api.getProject(projectId).then((data) => setProject(data)).catch(() => setProject(null))
|
|
|
|
|
}, [projectId])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
if (!repo || repo.type !== 'docker') {
|
|
|
|
|
if (!repoId || !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([])
|
|
|
|
|
})
|
|
|
|
|
api.listDockerImages(repoId)
|
|
|
|
|
.then((data) => { setImages(Array.isArray(data) ? data : []); setImagesError(null) })
|
|
|
|
|
.catch((err) => { setImagesError(err instanceof Error ? err.message : 'Failed to load images'); setImages([]) })
|
|
|
|
|
}, [repoId, repo])
|
|
|
|
|
|
|
|
|
|
// Load the tags of the image at the current path (if any). Pure namespaces
|
|
|
|
|
// (no image at this exact path) have no tags.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
if (!repo || repo.type !== 'docker') {
|
|
|
|
|
if (!repoId || !repo || repo.type !== 'docker' || !pathIsImage(images, path)) {
|
|
|
|
|
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])
|
|
|
|
|
api.listDockerTags(repoId, path)
|
|
|
|
|
.then((data) => { setTags(Array.isArray(data) ? data : []); setTagsError(null) })
|
|
|
|
|
.catch((err) => { setTagsError(err instanceof Error ? err.message : 'Failed to load tags'); setTags([]) })
|
|
|
|
|
}, [repoId, repo, path, images])
|
|
|
|
|
|
|
|
|
|
const enterFolder = (next: string) => {
|
|
|
|
|
setPath(next)
|
|
|
|
|
setSelectedTag(null)
|
|
|
|
|
setDetail(null)
|
|
|
|
|
setDetailError(null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleTagSelect = (tag: DockerTagInfo) => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
setSelectedTag(tag)
|
|
|
|
|
setDetail(null)
|
|
|
|
|
setDetailError(null)
|
|
|
|
|
setShowHistory(false)
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
api.getDockerManifest(repoId, tag.tag, path)
|
|
|
|
|
.then((data) => { setDetail(data); setDetailError(null) })
|
|
|
|
|
.catch((err) => setDetailError(err instanceof Error ? err.message : 'Failed to load manifest'))
|
|
|
|
|
.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([])
|
|
|
|
|
})
|
|
|
|
|
api.listDockerImages(repoId)
|
|
|
|
|
.then((data) => { setImages(Array.isArray(data) ? data : []); setImagesError(null) })
|
|
|
|
|
.catch((err) => { setImagesError(err instanceof Error ? err.message : 'Failed to load images'); 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([])
|
|
|
|
|
})
|
|
|
|
|
if (!repoId || !pathIsImage(images, path)) { setTags([]); return }
|
|
|
|
|
api.listDockerTags(repoId, path)
|
|
|
|
|
.then((data) => { setTags(Array.isArray(data) ? data : []); setTagsError(null) })
|
|
|
|
|
.catch((err) => { setTagsError(err instanceof Error ? err.message : 'Failed to load tags'); setTags([]) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteTag = async () => {
|
|
|
|
|
if (!repoId || !deleteTagName) return
|
|
|
|
|
if (deleteTagConfirm.trim() !== deleteTagName) {
|
|
|
|
|
setDeleteError('Type the tag name to confirm deletion.')
|
|
|
|
|
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('')
|
|
|
|
|
await api.deleteDockerTag(repoId, path, deleteTagName)
|
|
|
|
|
setDeleteTagOpen(false); setDeleteTagConfirm(''); setDeleteTagName('')
|
|
|
|
|
setDetail(null); setSelectedTag(null)
|
|
|
|
|
refreshTags()
|
|
|
|
|
setDetail(null)
|
|
|
|
|
setSelectedTag(null)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to delete tag'
|
|
|
|
|
setDeleteError(message)
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleting(false)
|
|
|
|
|
}
|
|
|
|
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete tag')
|
|
|
|
|
} finally { setDeleting(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteImage = async () => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
if (deleteImageConfirm.trim() !== deleteImageLabel) {
|
|
|
|
|
setDeleteError('Type the image name to confirm deletion.')
|
|
|
|
|
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('')
|
|
|
|
|
setDeleteImageOpen(false); setDeleteImageConfirm(''); setDeleteImageLabel('')
|
|
|
|
|
if (path === deleteImagePath) enterFolder(parentPath(deleteImagePath))
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete image')
|
|
|
|
|
} finally { setDeleting(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRenameTag = async () => {
|
|
|
|
|
if (!repoId) return
|
|
|
|
|
if (!renameTagFrom.trim() || !renameTagTo.trim()) {
|
|
|
|
|
setRenameError('Both tag names are required.')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!renameTagFrom.trim() || !renameTagTo.trim()) { setRenameError('Both tag names are required.'); return }
|
|
|
|
|
setRenameError(null)
|
|
|
|
|
setRenaming(true)
|
|
|
|
|
try {
|
|
|
|
|
await api.renameDockerTag(repoId, selectedImage, renameTagFrom.trim(), renameTagTo.trim())
|
|
|
|
|
setRenameTagOpen(false)
|
|
|
|
|
setRenameTagFrom('')
|
|
|
|
|
setRenameTagTo('')
|
|
|
|
|
await api.renameDockerTag(repoId, path, renameTagFrom.trim(), renameTagTo.trim())
|
|
|
|
|
setRenameTagOpen(false); setRenameTagFrom(''); setRenameTagTo('')
|
|
|
|
|
refreshTags()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to rename tag'
|
|
|
|
|
setRenameError(message)
|
|
|
|
|
} finally {
|
|
|
|
|
setRenaming(false)
|
|
|
|
|
}
|
|
|
|
|
setRenameError(err instanceof Error ? err.message : 'Failed to rename tag')
|
|
|
|
|
} finally { setRenaming(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleRenameImage = async () => {
|
|
|
|
@@ -279,56 +260,141 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
setRenaming(true)
|
|
|
|
|
try {
|
|
|
|
|
await api.renameDockerImage(repoId, renameImageFrom, renameImageTo.trim())
|
|
|
|
|
setRenameImageOpen(false)
|
|
|
|
|
setRenameImageFrom('')
|
|
|
|
|
setRenameImageFromLabel('')
|
|
|
|
|
setRenameImageOpen(false); setRenameImageFrom(''); setRenameImageFromLabel('')
|
|
|
|
|
if (path === renameImageFrom) enterFolder(renameImageTo.trim())
|
|
|
|
|
setRenameImageTo('')
|
|
|
|
|
setSelectedImage(renameImageTo.trim())
|
|
|
|
|
refreshImages()
|
|
|
|
|
refreshTags()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : 'Failed to rename image'
|
|
|
|
|
setRenameError(message)
|
|
|
|
|
} finally {
|
|
|
|
|
setRenaming(false)
|
|
|
|
|
}
|
|
|
|
|
setRenameError(err instanceof Error ? err.message : 'Failed to rename image')
|
|
|
|
|
} finally { setRenaming(false) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build a `docker pull` reference from the repo's registry URL, selected
|
|
|
|
|
// image, and selected tag. A pull reference is host[:port]/<repository>:<tag>
|
|
|
|
|
// — it must NOT include the URL scheme or the registry's /v2 API prefix; the
|
|
|
|
|
// docker client adds /v2/ and /manifests/<tag> itself per the Registry HTTP
|
|
|
|
|
// API V2 spec. docker_url is composed server-side as
|
|
|
|
|
// <PublicBaseURL>/v2/<project>/<repo>, so we strip both here.
|
|
|
|
|
const dockerPullRef = (): string => {
|
|
|
|
|
// registryBase = host[:port]/<project>/<repo>[/<image path>] — derived from the
|
|
|
|
|
// repo's docker_url with the scheme and the /v2 registry prefix stripped (the
|
|
|
|
|
// docker client adds those itself). No tag.
|
|
|
|
|
const registryBase = (): string => {
|
|
|
|
|
if (!repo?.docker_url) return ''
|
|
|
|
|
const trimmed: string = repo.docker_url.trim()
|
|
|
|
|
const absolute: string = trimmed.includes('://')
|
|
|
|
|
? trimmed
|
|
|
|
|
: `${window.location.origin}${trimmed.startsWith('/') ? '' : '/'}${trimmed}`
|
|
|
|
|
let base: string = absolute
|
|
|
|
|
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // drop scheme
|
|
|
|
|
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
|
|
|
.replace(/\/+$/, '')
|
|
|
|
|
.replace(/^([^/]+)\/v2(?=\/|$)/, '$1') // drop the /v2 registry prefix after the host
|
|
|
|
|
if (selectedImage) base = `${base}/${selectedImage}`
|
|
|
|
|
return `${base}:${selectedTag?.tag || 'latest'}`
|
|
|
|
|
.replace(/^([^/]+)\/v2(?=\/|$)/, '$1')
|
|
|
|
|
if (path) base = `${base}/${path}`
|
|
|
|
|
return base
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tagRef = (): string => selectedTag?.tag || 'latest'
|
|
|
|
|
|
|
|
|
|
// Full, directly-pullable reference: host[:port]/<repository>:<tag>.
|
|
|
|
|
const dockerPullRef = (): string => {
|
|
|
|
|
const base = registryBase()
|
|
|
|
|
return base ? `${base}:${tagRef()}` : ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Registry repository reference without the host, e.g.
|
|
|
|
|
// "pilot/donkey/team/api:v1.2.3" — the pull reference minus host[:port].
|
|
|
|
|
const imageRef = (): string => {
|
|
|
|
|
const base = registryBase()
|
|
|
|
|
if (!base) return ''
|
|
|
|
|
const slash = base.indexOf('/')
|
|
|
|
|
const repoPart = slash === -1 ? '' : base.slice(slash + 1)
|
|
|
|
|
return repoPart ? `${repoPart}:${tagRef()}` : tagRef()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metaRow = (label: string, value: ReactNode) => (
|
|
|
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '110px minmax(0, 1fr)', gap: 1, alignItems: 'baseline', minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>{label}</Typography>
|
|
|
|
|
<Box sx={{ minWidth: 0 }}>{value}</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const monoLines = (items: string[]) => (
|
|
|
|
|
<Box sx={{ display: 'grid', gap: 0.25, minWidth: 0 }}>
|
|
|
|
|
{items.map((it, i) => (
|
|
|
|
|
<Typography key={i} variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>{it}</Typography>
|
|
|
|
|
))}
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const sectionTitle = (text: string) => (
|
|
|
|
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 0.5, color: 'text.secondary', fontWeight: 600 }}>{text}</Typography>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const renderCopyField = (value: string, label: string) => (
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
|
|
|
|
<TextField
|
|
|
|
|
value={value}
|
|
|
|
|
size="small"
|
|
|
|
|
fullWidth
|
|
|
|
|
InputProps={{ readOnly: true }}
|
|
|
|
|
sx={{ '& input': { fontFamily: 'monospace', fontSize: 12 } }}
|
|
|
|
|
/>
|
|
|
|
|
<TextField value={value} size="small" fullWidth InputProps={{ readOnly: true }} sx={{ '& input': { fontFamily: 'monospace', fontSize: 12 } }} />
|
|
|
|
|
<IconButton size="small" onClick={() => copyText(value)} aria-label={`Copy ${label}`} sx={{ mt: 0.5 }}>
|
|
|
|
|
<ContentCopyIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const crumbs = path ? path.split('/') : []
|
|
|
|
|
|
|
|
|
|
const renderBreadcrumb = () => (
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap', minWidth: 0 }}>
|
|
|
|
|
<Typography
|
|
|
|
|
variant="body2"
|
|
|
|
|
onClick={() => enterFolder('')}
|
|
|
|
|
sx={{ cursor: 'pointer', color: path === '' ? 'text.primary' : 'primary.main' }}
|
|
|
|
|
>
|
|
|
|
|
<HomeOutlinedIcon fontSize="small" />
|
|
|
|
|
</Typography>
|
|
|
|
|
{crumbs.map((seg, i) => {
|
|
|
|
|
const crumbPath = crumbs.slice(0, i + 1).join('/')
|
|
|
|
|
const last = i === crumbs.length - 1
|
|
|
|
|
return (
|
|
|
|
|
<Box component="span" key={crumbPath} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" color="text.disabled">/</Typography>
|
|
|
|
|
<Typography
|
|
|
|
|
variant="body2"
|
|
|
|
|
onClick={() => enterFolder(crumbPath)}
|
|
|
|
|
sx={{ cursor: 'pointer', color: last ? 'text.primary' : 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
|
|
|
>
|
|
|
|
|
{seg}
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const folderActions = (folder: DockerFolder) => folder.isImage ? (
|
|
|
|
|
<>
|
|
|
|
|
<span title={canWrite ? 'Rename image' : 'You do not have permission to rename'}>
|
|
|
|
|
<IconButton size="small" disabled={!canWrite} aria-label={`Rename image ${folder.path}`}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
setRenameError(null)
|
|
|
|
|
setRenameImageFrom(folder.path)
|
|
|
|
|
setRenameImageFromLabel(folder.path)
|
|
|
|
|
setRenameImageTo(folder.path)
|
|
|
|
|
setRenameImageOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DriveFileRenameOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
<span title={canWrite ? 'Delete image' : 'You do not have permission to delete'}>
|
|
|
|
|
<IconButton size="small" disabled={!canWrite} aria-label={`Delete image ${folder.path}`}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
setDeleteError(null)
|
|
|
|
|
setDeleteImagePath(folder.path)
|
|
|
|
|
setDeleteImageLabel(folder.path)
|
|
|
|
|
setDeleteImageConfirm('')
|
|
|
|
|
setDeleteImageOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
) : null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ProjectPageFrame
|
|
|
|
|
projectId={projectId ?? ''}
|
|
|
|
@@ -346,84 +412,70 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
<Divider sx={{ mb: 1 }} />
|
|
|
|
|
<PageAlert message={loadError} onClose={() => setLoadError(null)} />
|
|
|
|
|
|
|
|
|
|
<SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={(
|
|
|
|
|
<SplitPanel defaultRatio={0.3} sx={{ mb: 1 }} left={(
|
|
|
|
|
<TintedPanel>
|
|
|
|
|
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
|
|
|
|
|
<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 ? 400 : 300 }}>
|
|
|
|
|
{image || '(root)'}
|
|
|
|
|
</Typography>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
{path !== '' ? (
|
|
|
|
|
<ListItem key="d:.." disablePadding>
|
|
|
|
|
<ListItemButton onClick={() => enterFolder(parentPath(path))}>
|
|
|
|
|
<FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
|
|
|
|
|
<ListItemText primary={<Typography variant="body2" sx={{ fontWeight: 300 }}>..</Typography>} />
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
</ListItem>
|
|
|
|
|
) : null}
|
|
|
|
|
{folders.map((folder) => (
|
|
|
|
|
<ListItem key={`d:${folder.path}`} disablePadding secondaryAction={folderActions(folder)}>
|
|
|
|
|
<ListItemButton onClick={() => enterFolder(folder.path)}>
|
|
|
|
|
<FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
|
|
|
|
|
<ListItemText primary={<Typography variant="body2" sx={{ fontWeight: 300 }}>{folder.name}</Typography>} />
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
<span title={canWrite ? 'Rename image' : 'You do not have permission to rename'}>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
disabled={!canWrite}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
setRenameError(null)
|
|
|
|
|
setRenameImageFrom(image)
|
|
|
|
|
setRenameImageFromLabel(image || '(root)')
|
|
|
|
|
setRenameImageTo(image || '')
|
|
|
|
|
setRenameImageOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Rename image ${image || '(root)'}`}
|
|
|
|
|
>
|
|
|
|
|
<DriveFileRenameOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
<span title={canWrite ? 'Delete image' : 'You do not have permission to delete'}>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
disabled={!canWrite}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
setDeleteError(null)
|
|
|
|
|
setDeleteImagePath(image)
|
|
|
|
|
setDeleteImageLabel(image || '(root)')
|
|
|
|
|
setDeleteImageConfirm('')
|
|
|
|
|
setDeleteImageOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Delete image ${image || '(root)'}`}
|
|
|
|
|
>
|
|
|
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
{!images.length && !imagesError ? (
|
|
|
|
|
{tags.map((tag) => (
|
|
|
|
|
<ListItem
|
|
|
|
|
key={`t:${tag.tag}`}
|
|
|
|
|
disablePadding
|
|
|
|
|
secondaryAction={
|
|
|
|
|
<>
|
|
|
|
|
<span title={canWrite ? 'Rename tag' : 'You do not have permission to rename'}>
|
|
|
|
|
<IconButton size="small" disabled={!canWrite} aria-label={`Rename tag ${tag.tag}`}
|
|
|
|
|
onClick={(event) => { event.stopPropagation(); setRenameError(null); setRenameTagFrom(tag.tag); setRenameTagTo(tag.tag); setRenameTagOpen(true) }}>
|
|
|
|
|
<DriveFileRenameOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
<span title={canWrite ? 'Delete tag' : 'You do not have permission to delete'}>
|
|
|
|
|
<IconButton size="small" disabled={!canWrite} aria-label={`Delete tag ${tag.tag}`}
|
|
|
|
|
onClick={(event) => { event.stopPropagation(); setDeleteError(null); setDeleteTagName(tag.tag); setDeleteTagConfirm(''); setDeleteTagOpen(true) }}>
|
|
|
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<ListItemButton selected={selectedTag?.tag === tag.tag} onClick={() => handleTagSelect(tag)}>
|
|
|
|
|
<LocalOfferOutlinedIcon fontSize="small" color="action" sx={{ mr: 1 }} />
|
|
|
|
|
<ListItemText
|
|
|
|
|
primary={<Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 400 : 300 }}>{tag.tag}</Typography>}
|
|
|
|
|
secondary={tag.digest ? tag.digest.slice(0, 19) : ''}
|
|
|
|
|
/>
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
{folders.length === 0 && tags.length === 0 && !imagesError && !tagsError ? (
|
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
|
|
|
|
No images found.
|
|
|
|
|
{currentIsImage ? 'No tags found.' : 'No images found.'}
|
|
|
|
|
</Typography>
|
|
|
|
|
) : null}
|
|
|
|
|
</List>
|
|
|
|
|
</TintedPanel>
|
|
|
|
|
)} right={(
|
|
|
|
|
<TintedPanel>
|
|
|
|
|
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
|
|
|
|
|
<TintedPanel sx={{ ml:1 }}>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, minHeight: 32 }}>
|
|
|
|
|
{selectedImage !== '' || images.includes('') ? (
|
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
|
|
|
|
Image: {selectedImage || '(root)'}
|
|
|
|
|
</Typography>
|
|
|
|
|
) : null}
|
|
|
|
|
{renderBreadcrumb()}
|
|
|
|
|
{repo?.docker_url ? (
|
|
|
|
|
<Box sx={{ marginLeft: 'auto' }}>
|
|
|
|
|
<PillNavButton icon={<CodeIcon />} onClick={(event) => setUseAnchor(event.currentTarget)}>
|
|
|
|
|
Use
|
|
|
|
|
</PillNavButton>
|
|
|
|
|
<PillNavButton icon={<CodeIcon />} onClick={(event) => setUseAnchor(event.currentTarget)}>Use</PillNavButton>
|
|
|
|
|
</Box>
|
|
|
|
|
) : null}
|
|
|
|
|
</Box>
|
|
|
|
@@ -438,117 +490,187 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
<Typography variant="subtitle2">Pull this image</Typography>
|
|
|
|
|
{renderCopyField(`docker pull ${dockerPullRef()}`, 'docker pull command')}
|
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
|
|
|
|
{selectedTag ? `Pulling tag "${selectedTag.tag}".` : 'Defaults to :latest — select a tag to pull a specific version.'}
|
|
|
|
|
{selectedTag ? `Pulling tag "${selectedTag.tag}".` : path ? 'Defaults to :latest — select a tag to pull a specific version.' : 'Open an image and select a tag to pull a specific version.'}
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
</Popover>
|
|
|
|
|
<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 ? 400 : 300 }}>
|
|
|
|
|
{tag.tag}
|
|
|
|
|
</Typography>
|
|
|
|
|
}
|
|
|
|
|
secondary={tag.digest ? tag.digest.slice(0, 12) : ''}
|
|
|
|
|
/>
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
<span title={canWrite ? 'Rename tag' : 'You do not have permission to rename'}>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
disabled={!canWrite}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRenameError(null)
|
|
|
|
|
setRenameTagFrom(tag.tag)
|
|
|
|
|
setRenameTagTo(tag.tag)
|
|
|
|
|
setRenameTagOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Rename tag ${tag.tag}`}
|
|
|
|
|
>
|
|
|
|
|
<DriveFileRenameOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
<span title={canWrite ? 'Delete tag' : 'You do not have permission to delete'}>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
disabled={!canWrite}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setDeleteError(null)
|
|
|
|
|
setDeleteTagName(tag.tag)
|
|
|
|
|
setDeleteTagConfirm('')
|
|
|
|
|
setDeleteTagOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Delete tag ${tag.tag}`}
|
|
|
|
|
>
|
|
|
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</span>
|
|
|
|
|
</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}
|
|
|
|
|
<PageAlert severity="warning" message={detailError} onClose={() => setDetailError(null)} />
|
|
|
|
|
{detail ? (
|
|
|
|
|
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
|
|
|
|
<Typography variant="body2">Tag: {detail.reference}</Typography>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
|
|
|
|
Digest: {detail.digest}
|
|
|
|
|
</Typography>
|
|
|
|
|
<Tooltip title="Copy digest">
|
|
|
|
|
<IconButton size="small" onClick={() => copyText(detail.digest)} aria-label="Copy digest" sx={{ flexShrink: 0 }}>
|
|
|
|
|
<ContentCopyIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Box>
|
|
|
|
|
<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 ? formatCommitTime(detail.config.created) : '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}
|
|
|
|
|
secondaryAction={
|
|
|
|
|
<Tooltip title="Copy layer digest">
|
|
|
|
|
<IconButton edge="end" size="small" onClick={() => copyText(layer.digest)} aria-label="Copy layer digest">
|
|
|
|
|
<ContentCopyIcon fontSize="small" />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<ListItemText
|
|
|
|
|
primary={<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{layer.digest}</Typography>}
|
|
|
|
|
secondary={`${layer.size} bytes`}
|
|
|
|
|
/>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
</List>
|
|
|
|
|
</Box>
|
|
|
|
|
<Divider sx={{ mb: 1 }} />
|
|
|
|
|
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
|
|
|
|
|
|
|
|
|
|
{selectedTag ? (
|
|
|
|
|
<>
|
|
|
|
|
{detailLoading ? <Typography variant="body2" color="text.secondary">Loading manifest...</Typography> : null}
|
|
|
|
|
<PageAlert severity="warning" message={detailError} onClose={() => setDetailError(null)} />
|
|
|
|
|
{detail ? (() => {
|
|
|
|
|
const cfg = detail.config || {}
|
|
|
|
|
const platformLabel = (p: DockerPlatformInfo): string => `${p.os}/${p.architecture}${p.variant ? '/' + p.variant : ''}${p.os_version ? ' (' + p.os_version + ')' : ''}`
|
|
|
|
|
const hasRuntime = Boolean(cfg.user || cfg.working_dir || cfg.stop_signal || cfg.entrypoint?.length || cfg.cmd?.length || cfg.env?.length || cfg.exposed_ports?.length || cfg.volumes?.length)
|
|
|
|
|
const labelKeys = cfg.labels ? Object.keys(cfg.labels).sort() : []
|
|
|
|
|
const history = (cfg.history || []).filter((h) => h.created_by)
|
|
|
|
|
return (
|
|
|
|
|
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>Image: {imageRef()}</Typography>
|
|
|
|
|
<Tooltip title="Copy image reference">
|
|
|
|
|
<IconButton size="small" onClick={() => copyText(imageRef())} aria-label="Copy image reference" sx={{ flexShrink: 0 }}><ContentCopyIcon fontSize="small" /></IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Box>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>Digest: {detail.digest}</Typography>
|
|
|
|
|
<Tooltip title="Copy digest">
|
|
|
|
|
<IconButton size="small" onClick={() => copyText(detail.digest)} aria-label="Copy digest" sx={{ flexShrink: 0 }}><ContentCopyIcon fontSize="small" /></IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</Box>
|
|
|
|
|
{metaRow('Media', <Typography variant="body2" sx={{ wordBreak: 'break-all' }}>{detail.media_type}</Typography>)}
|
|
|
|
|
{metaRow('Size', detail.size ? formatBytes(detail.size) : 'n/a')}
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
{metaRow('OS', `${cfg.os || 'n/a'}${cfg.os_version ? ' ' + cfg.os_version : ''}`)}
|
|
|
|
|
{metaRow('Architecture', cfg.architecture ? `${cfg.architecture}${cfg.variant ? '/' + cfg.variant : ''}` : 'n/a')}
|
|
|
|
|
{metaRow('Created', cfg.created ? formatCommitTime(cfg.created) : 'n/a')}
|
|
|
|
|
{cfg.author ? metaRow('Author', cfg.author) : null}
|
|
|
|
|
|
|
|
|
|
{detail.platforms?.length ? (
|
|
|
|
|
<>
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
{sectionTitle(`Platforms (${detail.platforms.length})`)}
|
|
|
|
|
<List dense>
|
|
|
|
|
{detail.platforms.map((p) => (
|
|
|
|
|
<ListItem key={p.digest} disableGutters dense sx={{ py: 0 }} secondaryAction={
|
|
|
|
|
<Tooltip title="Copy platform digest">
|
|
|
|
|
<IconButton edge="end" size="small" onClick={() => copyText(p.digest)} aria-label="Copy platform digest"><ContentCopyIcon fontSize="small" /></IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
}>
|
|
|
|
|
<ListItemText primary={
|
|
|
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: 2, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" noWrap sx={{ fontFamily: 'monospace' }}>{platformLabel(p)}</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>{p.size ? formatBytes(p.size) : ''}</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
} />
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
</List>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{hasRuntime ? (
|
|
|
|
|
<>
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
{sectionTitle('Configuration')}
|
|
|
|
|
{cfg.entrypoint?.length ? metaRow('Entrypoint', monoLines([cfg.entrypoint.join(' ')])) : null}
|
|
|
|
|
{cfg.cmd?.length ? metaRow('Cmd', monoLines([cfg.cmd.join(' ')])) : null}
|
|
|
|
|
{cfg.working_dir ? metaRow('Workdir', <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{cfg.working_dir}</Typography>) : null}
|
|
|
|
|
{cfg.user ? metaRow('User', <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{cfg.user}</Typography>) : null}
|
|
|
|
|
{cfg.exposed_ports?.length ? metaRow('Ports', (
|
|
|
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
|
|
|
{cfg.exposed_ports.map((port) => <Chip key={port} label={port} size="small" variant="outlined" />)}
|
|
|
|
|
</Box>
|
|
|
|
|
)) : null}
|
|
|
|
|
{cfg.volumes?.length ? metaRow('Volumes', monoLines(cfg.volumes)) : null}
|
|
|
|
|
{cfg.stop_signal ? metaRow('Stop signal', cfg.stop_signal) : null}
|
|
|
|
|
{cfg.env?.length ? metaRow('Env', monoLines(cfg.env)) : null}
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{labelKeys.length ? (
|
|
|
|
|
<>
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
{sectionTitle(`Labels (${labelKeys.length})`)}
|
|
|
|
|
<Box sx={{ display: 'grid', gap: 0.25, minWidth: 0 }}>
|
|
|
|
|
{labelKeys.map((k) => (
|
|
|
|
|
<Box key={k} sx={{ display: 'grid', gridTemplateColumns: 'minmax(120px, 38%) minmax(0, 1fr)', gap: 1, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: 'text.secondary', wordBreak: 'break-all' }}>{k}</Typography>
|
|
|
|
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>{cfg.labels?.[k] ?? ''}</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
))}
|
|
|
|
|
</Box>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
{sectionTitle(`Layers (${detail.layers?.length || 0})`)}
|
|
|
|
|
<List dense>
|
|
|
|
|
{detail.layers?.map((layer) => (
|
|
|
|
|
<ListItem key={layer.digest} disableGutters dense sx={{ py: 0 }} secondaryAction={
|
|
|
|
|
<Tooltip title="Copy layer digest">
|
|
|
|
|
<IconButton edge="end" size="small" onClick={() => copyText(layer.digest)} aria-label="Copy layer digest"><ContentCopyIcon fontSize="small" /></IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
}>
|
|
|
|
|
<ListItemText
|
|
|
|
|
primary={
|
|
|
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: 2, minWidth: 0 }}>
|
|
|
|
|
<Typography variant="body2" noWrap sx={{ fontFamily: 'monospace' }}>{layer.digest}</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>{formatBytes(layer.size)}</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
</List>
|
|
|
|
|
|
|
|
|
|
{history.length ? (
|
|
|
|
|
<>
|
|
|
|
|
<Divider sx={{ my: 0.5 }} />
|
|
|
|
|
<Box onClick={() => setShowHistory((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer' }}>
|
|
|
|
|
{sectionTitle(`Build history (${history.length})`)}
|
|
|
|
|
{showHistory ? <ExpandLessIcon fontSize="small" color="action" /> : <ExpandMoreIcon fontSize="small" color="action" />}
|
|
|
|
|
</Box>
|
|
|
|
|
{showHistory ? (
|
|
|
|
|
<LazyCodeBlock code={history.map((h) => h.created_by || '').join('\n')} language="bash" showLineNumbers wrap />
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</Box>
|
|
|
|
|
)
|
|
|
|
|
})() : null}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
|
|
|
Select a tag to view details.
|
|
|
|
|
</Typography>
|
|
|
|
|
<List dense>
|
|
|
|
|
{path !== '' ? (
|
|
|
|
|
<ListItem key="rd:.." disablePadding>
|
|
|
|
|
<ListItemButton onClick={() => enterFolder(parentPath(path))}>
|
|
|
|
|
<FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
|
|
|
|
|
<ListItemText primary={<Typography variant="body2">..</Typography>} />
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
</ListItem>
|
|
|
|
|
) : null}
|
|
|
|
|
{folders.map((folder) => (
|
|
|
|
|
<ListItem key={`rd:${folder.path}`} disablePadding>
|
|
|
|
|
<ListItemButton onClick={() => enterFolder(folder.path)}>
|
|
|
|
|
<FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
|
|
|
|
|
<ListItemText primary={<Typography variant="body2">{folder.name}</Typography>} />
|
|
|
|
|
<Chip size="small" variant="outlined" sx={{ height: 18 }} label={folder.isImage ? 'image' : 'namespace'} />
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
{tags.map((tag) => (
|
|
|
|
|
<ListItem key={`rt:${tag.tag}`} disablePadding>
|
|
|
|
|
<ListItemButton onClick={() => handleTagSelect(tag)}>
|
|
|
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: 2, width: '100%', minWidth: 0 }}>
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
|
|
|
|
<LocalOfferOutlinedIcon fontSize="small" color="action" />
|
|
|
|
|
<Typography variant="body2" noWrap>{tag.tag}</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
|
|
|
|
{tag.digest ? tag.digest.slice(0, 19) : ''}{tag.size ? ` · ${formatBytes(tag.size)}` : ''}
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
{folders.length === 0 && tags.length === 0 ? (
|
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
|
|
|
|
|
{currentIsImage ? 'No tags in this image.' : 'This namespace is empty.'}
|
|
|
|
|
</Typography>
|
|
|
|
|
) : (
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ px: 1 }}>
|
|
|
|
|
Select a tag to view its manifest.
|
|
|
|
|
</Typography>
|
|
|
|
|
)}
|
|
|
|
|
</List>
|
|
|
|
|
)}
|
|
|
|
|
</TintedPanel>
|
|
|
|
|
)} />
|
|
|
|
|
|
|
|
|
|
<ConfirmDeleteDialog
|
|
|
|
|
open={deleteTagOpen}
|
|
|
|
|
title="Delete tag"
|
|
|
|
@@ -562,9 +684,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
onConfirm={handleDeleteTag}
|
|
|
|
|
onClose={() => setDeleteTagOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
<Typography variant="body2">
|
|
|
|
|
Delete tag "{deleteTagName}"?
|
|
|
|
|
</Typography>
|
|
|
|
|
<Typography variant="body2">Delete tag "{deleteTagName}"?</Typography>
|
|
|
|
|
</ConfirmDeleteDialog>
|
|
|
|
|
<ConfirmDeleteDialog
|
|
|
|
|
open={deleteImageOpen}
|
|
|
|
@@ -579,9 +699,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
onConfirm={handleDeleteImage}
|
|
|
|
|
onClose={() => setDeleteImageOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
<Typography variant="body2">
|
|
|
|
|
Delete image "{deleteImageLabel || '(root)'}" and all tags?
|
|
|
|
|
</Typography>
|
|
|
|
|
<Typography variant="body2">Delete image "{deleteImageLabel}" and all its tags?</Typography>
|
|
|
|
|
</ConfirmDeleteDialog>
|
|
|
|
|
<DockerRenameDialog
|
|
|
|
|
open={renameTagOpen}
|
|
|
|
@@ -601,7 +719,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
|
|
|
|
from={renameImageFromLabel}
|
|
|
|
|
to={renameImageTo}
|
|
|
|
|
onToChange={setRenameImageTo}
|
|
|
|
|
toHelperText="Leave blank to move to root."
|
|
|
|
|
toHelperText="Full image path; leave blank to move to root."
|
|
|
|
|
busy={renaming}
|
|
|
|
|
onClose={() => setRenameImageOpen(false)}
|
|
|
|
|
onRename={handleRenameImage}
|
|
|
|
|