Compare commits

..

3 Commits

Author SHA1 Message Date
hyung-hwan 2094037a5d update CodeBlock/LazyCodeBlock to support line wrap.
docker history to wrap long lines
2026-06-29 00:13:15 +09:00
hyung-hwan a5ef2b51a8 enhanced the docker page to show more info 2026-06-29 00:08:41 +09:00
hyung-hwan 19e6bd190c enhancing docker detail page 2026-06-28 23:55:27 +09:00
6 changed files with 794 additions and 361 deletions
+234 -15
View File
@@ -6,6 +6,7 @@ import "errors"
import "io/fs"
import "os"
import "path/filepath"
import "sort"
import "strings"
type TagInfo struct {
@@ -22,12 +23,47 @@ type ManifestDetail struct {
Size int64 `json:"size"`
Config ImageConfig `json:"config"`
Layers []ociDescriptor `json:"layers"`
Platforms []PlatformInfo `json:"platforms,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
type ImageConfig struct {
Created string `json:"created"`
Author string `json:"author,omitempty"`
Architecture string `json:"architecture"`
OS string `json:"os"`
OSVersion string `json:"os_version,omitempty"`
Variant string `json:"variant,omitempty"`
User string `json:"user,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Entrypoint []string `json:"entrypoint,omitempty"`
Cmd []string `json:"cmd,omitempty"`
Env []string `json:"env,omitempty"`
ExposedPorts []string `json:"exposed_ports,omitempty"`
Volumes []string `json:"volumes,omitempty"`
StopSignal string `json:"stop_signal,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
History []HistoryEntry `json:"history,omitempty"`
}
// HistoryEntry mirrors one entry of the image config's "history" array (the
// build steps shown by `docker history`).
type HistoryEntry struct {
Created string `json:"created,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
Comment string `json:"comment,omitempty"`
EmptyLayer bool `json:"empty_layer,omitempty"`
}
// PlatformInfo describes one entry of a multi-arch manifest list / image index.
type PlatformInfo struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
Variant string `json:"variant,omitempty"`
OSVersion string `json:"os_version,omitempty"`
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"media_type,omitempty"`
}
type manifestPayload struct {
@@ -37,6 +73,52 @@ type manifestPayload struct {
Layers []ociDescriptor `json:"layers"`
}
// indexPayload mirrors a manifest list / image index document.
type indexPayload struct {
MediaType string `json:"mediaType"`
Manifests []indexManifestEntry `json:"manifests"`
Annotations map[string]string `json:"annotations"`
}
type indexManifestEntry struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
Platform *ociPlatform `json:"platform"`
}
type ociPlatform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
OSVersion string `json:"os.version"`
Variant string `json:"variant"`
}
// rawImageConfig is the on-disk OCI image config blob. It is flattened into
// ImageConfig for the API response.
type rawImageConfig struct {
Created string `json:"created"`
Author string `json:"author"`
Architecture string `json:"architecture"`
OS string `json:"os"`
OSVersion string `json:"os.version"`
Variant string `json:"variant"`
Config rawRuntimeConfig `json:"config"`
History []HistoryEntry `json:"history"`
}
type rawRuntimeConfig struct {
User string `json:"User"`
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
Env []string `json:"Env"`
Entrypoint []string `json:"Entrypoint"`
Cmd []string `json:"Cmd"`
Volumes map[string]struct{} `json:"Volumes"`
WorkingDir string `json:"WorkingDir"`
Labels map[string]string `json:"Labels"`
StopSignal string `json:"StopSignal"`
}
func ListTags(repoPath string) ([]TagInfo, error) {
var tags []string
var err error
@@ -254,9 +336,6 @@ func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error
var desc ociDescriptor
var err error
var data []byte
var payload manifestPayload
var configData []byte
var config ImageConfig
desc, err = resolveManifest(repoPath, reference)
if err != nil {
return detail, err
@@ -265,24 +344,164 @@ func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error
if err != nil {
return detail, err
}
if desc.MediaType == "" {
desc.MediaType = detectManifestMediaType(data)
}
detail.Reference = reference
detail.Digest = desc.Digest
detail.MediaType = desc.MediaType
if isIndexMediaType(desc.MediaType) || looksLikeIndex(data) {
describeIndex(repoPath, data, &detail)
return detail, nil
}
describeManifest(repoPath, data, &detail)
return detail, nil
}
// describeManifest fills Config/Layers/Size for a single (non-list) image
// manifest document.
func describeManifest(repoPath string, data []byte, detail *ManifestDetail) {
var payload manifestPayload
var configData []byte
var raw rawImageConfig
var err error
err = json.Unmarshal(data, &payload)
if err != nil {
return detail, err
return
}
if payload.MediaType != "" {
desc.MediaType = payload.MediaType
if payload.MediaType != "" && detail.MediaType == "" {
detail.MediaType = payload.MediaType
}
detail.Layers = payload.Layers
detail.Size = payload.Config.Size
for _, layer := range payload.Layers {
detail.Size += layer.Size
}
configData, err = ReadBlob(repoPath, payload.Config.Digest)
if err == nil {
_ = json.Unmarshal(configData, &config)
err = json.Unmarshal(configData, &raw)
if err == nil {
detail.Config = flattenImageConfig(raw)
}
detail = ManifestDetail{
Reference: reference,
Digest: desc.Digest,
MediaType: desc.MediaType,
Size: int64(len(data)),
Config: config,
Layers: payload.Layers,
}
return detail, nil
}
// describeIndex fills Platforms/Annotations for a manifest list / image index,
// then drills into a preferred platform so the runtime config and layers of the
// default image are still surfaced.
func describeIndex(repoPath string, data []byte, detail *ManifestDetail) {
var idx indexPayload
var entry indexManifestEntry
var platform PlatformInfo
var preferred string
var sub []byte
var err error
err = json.Unmarshal(data, &idx)
if err != nil {
return
}
detail.Annotations = idx.Annotations
for _, entry = range idx.Manifests {
platform = PlatformInfo{
Digest: entry.Digest,
Size: entry.Size,
MediaType: entry.MediaType,
}
if entry.Platform != nil {
platform.OS = entry.Platform.OS
platform.Architecture = entry.Platform.Architecture
platform.Variant = entry.Platform.Variant
platform.OSVersion = entry.Platform.OSVersion
}
// Skip attestation/unknown entries that carry no real platform.
if platform.Architecture == "unknown" || platform.OS == "unknown" {
continue
}
detail.Platforms = append(detail.Platforms, platform)
}
preferred = preferredPlatformDigest(detail.Platforms)
if preferred == "" {
return
}
sub, err = ReadBlob(repoPath, preferred)
if err != nil {
return
}
describeManifest(repoPath, sub, detail)
}
// preferredPlatformDigest picks linux/amd64 if present, else the first entry.
func preferredPlatformDigest(platforms []PlatformInfo) string {
var p PlatformInfo
for _, p = range platforms {
if p.OS == "linux" && p.Architecture == "amd64" {
return p.Digest
}
}
if len(platforms) > 0 {
return platforms[0].Digest
}
return ""
}
func flattenImageConfig(raw rawImageConfig) ImageConfig {
var cfg ImageConfig
cfg.Created = raw.Created
cfg.Author = raw.Author
cfg.Architecture = raw.Architecture
cfg.OS = raw.OS
cfg.OSVersion = raw.OSVersion
cfg.Variant = raw.Variant
cfg.User = raw.Config.User
cfg.WorkingDir = raw.Config.WorkingDir
cfg.Entrypoint = raw.Config.Entrypoint
cfg.Cmd = raw.Config.Cmd
cfg.Env = raw.Config.Env
cfg.ExposedPorts = sortedKeys(raw.Config.ExposedPorts)
cfg.Volumes = sortedKeys(raw.Config.Volumes)
cfg.StopSignal = raw.Config.StopSignal
cfg.Labels = raw.Config.Labels
cfg.History = raw.History
return cfg
}
func sortedKeys(m map[string]struct{}) []string {
var keys []string
var k string
if len(m) == 0 {
return nil
}
keys = make([]string, 0, len(m))
for k = range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func isIndexMediaType(mediaType string) bool {
switch mediaType {
case "application/vnd.oci.image.index.v1+json":
return true
case "application/vnd.docker.distribution.manifest.list.v2+json":
return true
}
return false
}
// looksLikeIndex detects an index document that omits a top-level mediaType: it
// has a "manifests" array and no "layers"/"config".
func looksLikeIndex(data []byte) bool {
var probe struct {
Manifests []json.RawMessage `json:"manifests"`
Layers []json.RawMessage `json:"layers"`
Config json.RawMessage `json:"config"`
}
var err error
err = json.Unmarshal(data, &probe)
if err != nil {
return false
}
return len(probe.Manifests) > 0 && len(probe.Layers) == 0 && len(probe.Config) == 0
}
+39 -5
View File
@@ -184,21 +184,55 @@ export interface DockerTagInfo {
media_type: string
}
export interface DockerHistoryEntry {
created?: string
created_by?: string
comment?: string
empty_layer?: boolean
}
export interface DockerPlatformInfo {
os: string
architecture: string
variant?: string
os_version?: string
digest: string
size: number
media_type?: string
}
export interface DockerImageConfig {
created?: string
author?: string
architecture?: string
os?: string
os_version?: string
variant?: string
user?: string
working_dir?: string
entrypoint?: string[]
cmd?: string[]
env?: string[]
exposed_ports?: string[]
volumes?: string[]
stop_signal?: string
labels?: Record<string, string>
history?: DockerHistoryEntry[]
}
export interface DockerManifestDetail {
reference: string
digest: string
media_type: string
size: number
config: {
created?: string
architecture?: string
os?: string
}
config: DockerImageConfig
layers: {
mediaType: string
digest: string
size: number
}[]
platforms?: DockerPlatformInfo[]
annotations?: Record<string, string>
}
export interface RpmTreeEntry {
+58 -2
View File
@@ -1,5 +1,5 @@
import Prism from 'prismjs'
import { useMemo } from 'react'
import { Fragment, useMemo } from 'react'
import 'prismjs/components/prism-diff'
import 'prismjs/components/prism-markup'
@@ -23,11 +23,12 @@ interface CodeBlockProps {
code?: string
language?: string
showLineNumbers?: boolean
wrap?: boolean
}
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export default function CodeBlock({ code = '', language = 'diff', showLineNumbers = false }: CodeBlockProps) {
export default function CodeBlock({ code = '', language = 'diff', showLineNumbers = false, wrap = false }: CodeBlockProps) {
const lines = useMemo(() => {
const rawLines = code.replace(/\n$/, '').split('\n')
return rawLines.length === 0 ? [''] : rawLines
@@ -35,6 +36,61 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
const highlightedLines = useMemo(() => {
return lines.map((line) => Prism.highlight(line, Prism.languages[language] || Prism.languages.diff, language))
}, [lines, language])
// Wrapping layout: each logical line is its own grid row (number cell + code
// cell), so a line that wraps to several visual lines still shows a single
// line number anchored to the top of the group.
if (wrap) {
return (
<pre
className={`language-${language}`}
style={{
margin: 0,
padding: 0,
width: '100%',
maxWidth: '100%',
minWidth: 0,
backgroundColor: 'transparent',
lineHeight: 1.4,
fontSize: '0.85rem',
fontFamily: codeFontFamily,
display: 'grid',
gridTemplateColumns: showLineNumbers ? 'auto 1fr' : '1fr',
columnGap: showLineNumbers ? 12 : 0
}}
>
{highlightedLines.map((line, idx) => (
<Fragment key={`row-${idx}`}>
{showLineNumbers ? (
<span
style={{
userSelect: 'none',
color: '#9ca3af',
textAlign: 'right',
paddingRight: 8,
borderRight: '1px solid rgba(0,0,0,0.08)',
whiteSpace: 'nowrap',
alignSelf: 'start'
}}
>
{idx + 1}
</span>
) : null}
<code
style={{
backgroundColor: 'transparent',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
minWidth: 0
}}
dangerouslySetInnerHTML={{ __html: line || '&nbsp;' }}
/>
</Fragment>
))}
</pre>
)
}
return (
<pre
className={`language-${language}`}
+5 -3
View File
@@ -5,13 +5,14 @@ type LazyCodeBlockProps = {
code?: string
language?: string
showLineNumbers?: boolean
wrap?: boolean
}
const CodeBlock = lazy(() => import('./CodeBlock'))
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export default function LazyCodeBlock(props: LazyCodeBlockProps) {
const { code = '' } = props
const { code = '', wrap = false } = props
return (
<Suspense
@@ -21,8 +22,9 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
sx={{
m: 0,
p: 0,
whiteSpace: 'pre',
overflow: 'auto',
whiteSpace: wrap ? 'pre-wrap' : 'pre',
overflowWrap: wrap ? 'anywhere' : 'normal',
overflow: wrap ? 'visible' : 'auto',
width: '100%',
maxWidth: '100%',
minWidth: 0,
+4
View File
@@ -1,4 +1,5 @@
import BadgeIcon from '@mui/icons-material/Badge'
import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'
import HttpsIcon from '@mui/icons-material/Https'
import KeyIcon from '@mui/icons-material/Key'
import SecurityIcon from '@mui/icons-material/Security'
@@ -550,6 +551,9 @@ export default function DashboardPage() {
<Button component={Link} to="/repos" variant="outlined" size="small" startIcon={<StorageIcon />}>
Repositories
</Button>
<Button component={Link} to="/boards" variant="outlined" size="small" startIcon={<DashboardCustomizeIcon />}>
Boards
</Button>
<Button component={Link} to="/api-keys" variant="outlined" size="small" startIcon={<KeyIcon />}>
API Keys
</Button>
+421 -303
View File
@@ -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>
<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)'}`}
>
</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>
</ListItem>
))}
{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 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)'}`}
>
<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>
))}
{!images.length && !imagesError ? (
{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}
<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 ? (
{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 }}>
<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>
<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>
<Typography variant="body2">Media: {detail.media_type}</Typography>
<Typography variant="body2">Size: {detail.size} bytes</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>
{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 }} />
<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>
{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 }} />
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography>
{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}
secondaryAction={
<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>
<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`}
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.
<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}