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
+243 -24
View File
@@ -6,6 +6,7 @@ import "errors"
import "io/fs" import "io/fs"
import "os" import "os"
import "path/filepath" import "path/filepath"
import "sort"
import "strings" import "strings"
type TagInfo struct { type TagInfo struct {
@@ -16,18 +17,53 @@ type TagInfo struct {
} }
type ManifestDetail struct { type ManifestDetail struct {
Reference string `json:"reference"` Reference string `json:"reference"`
Digest string `json:"digest"` Digest string `json:"digest"`
MediaType string `json:"media_type"` MediaType string `json:"media_type"`
Size int64 `json:"size"` Size int64 `json:"size"`
Config ImageConfig `json:"config"` Config ImageConfig `json:"config"`
Layers []ociDescriptor `json:"layers"` Layers []ociDescriptor `json:"layers"`
Platforms []PlatformInfo `json:"platforms,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
} }
type ImageConfig struct { type ImageConfig struct {
Created string `json:"created"` Created string `json:"created"`
Architecture string `json:"architecture"` 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"` 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 { type manifestPayload struct {
@@ -37,6 +73,52 @@ type manifestPayload struct {
Layers []ociDescriptor `json:"layers"` 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) { func ListTags(repoPath string) ([]TagInfo, error) {
var tags []string var tags []string
var err error var err error
@@ -254,9 +336,6 @@ func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error
var desc ociDescriptor var desc ociDescriptor
var err error var err error
var data []byte var data []byte
var payload manifestPayload
var configData []byte
var config ImageConfig
desc, err = resolveManifest(repoPath, reference) desc, err = resolveManifest(repoPath, reference)
if err != nil { if err != nil {
return detail, err return detail, err
@@ -265,24 +344,164 @@ func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error
if err != nil { if err != nil {
return detail, err 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) err = json.Unmarshal(data, &payload)
if err != nil { if err != nil {
return detail, err return
} }
if payload.MediaType != "" { if payload.MediaType != "" && detail.MediaType == "" {
desc.MediaType = payload.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) configData, err = ReadBlob(repoPath, payload.Config.Digest)
if err == nil { 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, // describeIndex fills Platforms/Annotations for a manifest list / image index,
MediaType: desc.MediaType, // then drills into a preferred platform so the runtime config and layers of the
Size: int64(len(data)), // default image are still surfaced.
Config: config, func describeIndex(repoPath string, data []byte, detail *ManifestDetail) {
Layers: payload.Layers, var idx indexPayload
} var entry indexManifestEntry
return detail, nil 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 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 { export interface DockerManifestDetail {
reference: string reference: string
digest: string digest: string
media_type: string media_type: string
size: number size: number
config: { config: DockerImageConfig
created?: string
architecture?: string
os?: string
}
layers: { layers: {
mediaType: string mediaType: string
digest: string digest: string
size: number size: number
}[] }[]
platforms?: DockerPlatformInfo[]
annotations?: Record<string, string>
} }
export interface RpmTreeEntry { export interface RpmTreeEntry {
+58 -2
View File
@@ -1,5 +1,5 @@
import Prism from 'prismjs' import Prism from 'prismjs'
import { useMemo } from 'react' import { Fragment, useMemo } from 'react'
import 'prismjs/components/prism-diff' import 'prismjs/components/prism-diff'
import 'prismjs/components/prism-markup' import 'prismjs/components/prism-markup'
@@ -23,11 +23,12 @@ interface CodeBlockProps {
code?: string code?: string
language?: string language?: string
showLineNumbers?: boolean showLineNumbers?: boolean
wrap?: boolean
} }
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' 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 lines = useMemo(() => {
const rawLines = code.replace(/\n$/, '').split('\n') const rawLines = code.replace(/\n$/, '').split('\n')
return rawLines.length === 0 ? [''] : rawLines return rawLines.length === 0 ? [''] : rawLines
@@ -35,6 +36,61 @@ export default function CodeBlock({ code = '', language = 'diff', showLineNumber
const highlightedLines = useMemo(() => { const highlightedLines = useMemo(() => {
return lines.map((line) => Prism.highlight(line, Prism.languages[language] || Prism.languages.diff, language)) return lines.map((line) => Prism.highlight(line, Prism.languages[language] || Prism.languages.diff, language))
}, [lines, 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 ( return (
<pre <pre
className={`language-${language}`} className={`language-${language}`}
+5 -3
View File
@@ -5,13 +5,14 @@ type LazyCodeBlockProps = {
code?: string code?: string
language?: string language?: string
showLineNumbers?: boolean showLineNumbers?: boolean
wrap?: boolean
} }
const CodeBlock = lazy(() => import('./CodeBlock')) const CodeBlock = lazy(() => import('./CodeBlock'))
const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const codeFontFamily: string = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export default function LazyCodeBlock(props: LazyCodeBlockProps) { export default function LazyCodeBlock(props: LazyCodeBlockProps) {
const { code = '' } = props const { code = '', wrap = false } = props
return ( return (
<Suspense <Suspense
@@ -21,8 +22,9 @@ export default function LazyCodeBlock(props: LazyCodeBlockProps) {
sx={{ sx={{
m: 0, m: 0,
p: 0, p: 0,
whiteSpace: 'pre', whiteSpace: wrap ? 'pre-wrap' : 'pre',
overflow: 'auto', overflowWrap: wrap ? 'anywhere' : 'normal',
overflow: wrap ? 'visible' : 'auto',
width: '100%', width: '100%',
maxWidth: '100%', maxWidth: '100%',
minWidth: 0, minWidth: 0,
+4
View File
@@ -1,4 +1,5 @@
import BadgeIcon from '@mui/icons-material/Badge' import BadgeIcon from '@mui/icons-material/Badge'
import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'
import HttpsIcon from '@mui/icons-material/Https' import HttpsIcon from '@mui/icons-material/Https'
import KeyIcon from '@mui/icons-material/Key' import KeyIcon from '@mui/icons-material/Key'
import SecurityIcon from '@mui/icons-material/Security' 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 />}> <Button component={Link} to="/repos" variant="outlined" size="small" startIcon={<StorageIcon />}>
Repositories Repositories
</Button> </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 />}> <Button component={Link} to="/api-keys" variant="outlined" size="small" startIcon={<KeyIcon />}>
API Keys API Keys
</Button> </Button>
+445 -327
View File
@@ -1,5 +1,6 @@
import { import {
Box, Box,
Chip,
Divider, Divider,
IconButton, IconButton,
List, List,
@@ -11,10 +12,11 @@ import {
Tooltip, Tooltip,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { useEffect, useRef, useState } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' 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 { formatCommitTime } from '../time'
import { formatBytes } from '../components/cardDetailUtils'
import { copyText } from '../clipboard' import { copyText } from '../clipboard'
import ContextToolbar from '../components/ContextToolbar' import ContextToolbar from '../components/ContextToolbar'
import ProjectPageFrame from '../components/ProjectPageFrame' import ProjectPageFrame from '../components/ProjectPageFrame'
@@ -28,13 +30,62 @@ import CodeIcon from '@mui/icons-material/Code'
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' 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 ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import LazyCodeBlock from '../components/LazyCodeBlock'
import DockerRenameDialog from '../components/DockerRenameDialog' import DockerRenameDialog from '../components/DockerRenameDialog'
type RepoDockerDetailPageProps = { type RepoDockerDetailPageProps = {
initialRepo?: Repo 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) { export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const { initialRepo } = props const { initialRepo } = props
const { projectId, repoId } = useParams() const { projectId, repoId } = useParams()
@@ -44,13 +95,14 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [imagesError, setImagesError] = useState<string | null>(null) const [imagesError, setImagesError] = useState<string | null>(null)
const [selectedImage, setSelectedImage] = useState('') const [path, setPath] = useState('')
const [tags, setTags] = useState<DockerTagInfo[]>([]) const [tags, setTags] = useState<DockerTagInfo[]>([])
const [tagsError, setTagsError] = useState<string | null>(null) const [tagsError, setTagsError] = useState<string | null>(null)
const [selectedTag, setSelectedTag] = useState<DockerTagInfo | null>(null) const [selectedTag, setSelectedTag] = useState<DockerTagInfo | null>(null)
const [detail, setDetail] = useState<DockerManifestDetail | null>(null) const [detail, setDetail] = useState<DockerManifestDetail | null>(null)
const [detailError, setDetailError] = useState<string | null>(null) const [detailError, setDetailError] = useState<string | null>(null)
const [detailLoading, setDetailLoading] = useState(false) const [detailLoading, setDetailLoading] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [deleteTagOpen, setDeleteTagOpen] = useState(false) const [deleteTagOpen, setDeleteTagOpen] = useState(false)
const [deleteTagName, setDeleteTagName] = useState('') const [deleteTagName, setDeleteTagName] = useState('')
const [deleteTagConfirm, setDeleteTagConfirm] = useState('') const [deleteTagConfirm, setDeleteTagConfirm] = useState('')
@@ -74,6 +126,9 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const initProjectRef = useRef<string | null>(null) const initProjectRef = useRef<string | null>(null)
const canWrite = repoEdit.canWrite const canWrite = repoEdit.canWrite
const folders = childFolders(images, path)
const currentIsImage = pathIsImage(images, path)
useEffect(() => { useEffect(() => {
if (!repoId) return if (!repoId) return
if (initRepoRef.current === repoId) return if (initRepoRef.current === repoId) return
@@ -84,8 +139,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
return return
} }
api.getRepo(repoId).then((data) => setRepo(data)).catch((err) => { api.getRepo(repoId).then((data) => setRepo(data)).catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load repository' setLoadError(err instanceof Error ? err.message : 'Failed to load repository')
setLoadError(message)
setRepo(null) setRepo(null)
}) })
}, [repoId, initialRepo]) }, [repoId, initialRepo])
@@ -94,183 +148,110 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
if (!projectId) return if (!projectId) return
if (initProjectRef.current === projectId) return if (initProjectRef.current === projectId) return
initProjectRef.current = projectId initProjectRef.current = projectId
api api.getProject(projectId).then((data) => setProject(data)).catch(() => setProject(null))
.getProject(projectId)
.then((data) => setProject(data))
.catch(() => setProject(null))
}, [projectId]) }, [projectId])
useEffect(() => { useEffect(() => {
if (!repoId) return if (!repoId || !repo || repo.type !== 'docker') {
if (!repo || repo.type !== 'docker') {
setImages([]) setImages([])
setImagesError(null) setImagesError(null)
setSelectedImage('')
return return
} }
api api.listDockerImages(repoId)
.listDockerImages(repoId) .then((data) => { setImages(Array.isArray(data) ? data : []); setImagesError(null) })
.then((data) => { .catch((err) => { setImagesError(err instanceof Error ? err.message : 'Failed to load images'); setImages([]) })
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !selectedImage) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
}, [repoId, repo]) }, [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(() => { useEffect(() => {
if (!repoId) return if (!repoId || !repo || repo.type !== 'docker' || !pathIsImage(images, path)) {
if (!repo || repo.type !== 'docker') {
setTags([]) setTags([])
setTagsError(null) setTagsError(null)
return return
} }
api api.listDockerTags(repoId, path)
.listDockerTags(repoId, selectedImage) .then((data) => { setTags(Array.isArray(data) ? data : []); setTagsError(null) })
.then((data) => { .catch((err) => { setTagsError(err instanceof Error ? err.message : 'Failed to load tags'); setTags([]) })
setTags(Array.isArray(data) ? data : []) }, [repoId, repo, path, images])
setTagsError(null)
}) const enterFolder = (next: string) => {
.catch((err) => { setPath(next)
const message = err instanceof Error ? err.message : 'Failed to load tags' setSelectedTag(null)
setTagsError(message) setDetail(null)
setTags([]) setDetailError(null)
}) }
}, [repoId, repo, selectedImage])
const handleTagSelect = (tag: DockerTagInfo) => { const handleTagSelect = (tag: DockerTagInfo) => {
if (!repoId) return if (!repoId) return
setSelectedTag(tag) setSelectedTag(tag)
setDetail(null) setDetail(null)
setDetailError(null) setDetailError(null)
setShowHistory(false)
setDetailLoading(true) setDetailLoading(true)
api api.getDockerManifest(repoId, tag.tag, path)
.getDockerManifest(repoId, tag.tag, selectedImage) .then((data) => { setDetail(data); setDetailError(null) })
.then((data) => { .catch((err) => setDetailError(err instanceof Error ? err.message : 'Failed to load manifest'))
setDetail(data) .finally(() => setDetailLoading(false))
setDetailError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load manifest'
setDetailError(message)
})
.finally(() => {
setDetailLoading(false)
})
} }
const refreshImages = () => { const refreshImages = () => {
if (!repoId) return if (!repoId) return
api api.listDockerImages(repoId)
.listDockerImages(repoId) .then((data) => { setImages(Array.isArray(data) ? data : []); setImagesError(null) })
.then((data) => { .catch((err) => { setImagesError(err instanceof Error ? err.message : 'Failed to load images'); setImages([]) })
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !list.includes(selectedImage)) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
} }
const refreshTags = () => { const refreshTags = () => {
if (!repoId) return if (!repoId || !pathIsImage(images, path)) { setTags([]); return }
api api.listDockerTags(repoId, path)
.listDockerTags(repoId, selectedImage) .then((data) => { setTags(Array.isArray(data) ? data : []); setTagsError(null) })
.then((data) => { .catch((err) => { setTagsError(err instanceof Error ? err.message : 'Failed to load tags'); setTags([]) })
setTags(Array.isArray(data) ? data : [])
setTagsError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load tags'
setTagsError(message)
setTags([])
})
} }
const handleDeleteTag = async () => { const handleDeleteTag = async () => {
if (!repoId || !deleteTagName) return if (!repoId || !deleteTagName) return
if (deleteTagConfirm.trim() !== deleteTagName) { if (deleteTagConfirm.trim() !== deleteTagName) { setDeleteError('Type the tag name to confirm deletion.'); return }
setDeleteError('Type the tag name to confirm deletion.')
return
}
setDeleteError(null) setDeleteError(null)
setDeleting(true) setDeleting(true)
try { try {
await api.deleteDockerTag(repoId, selectedImage, deleteTagName) await api.deleteDockerTag(repoId, path, deleteTagName)
setDeleteTagOpen(false) setDeleteTagOpen(false); setDeleteTagConfirm(''); setDeleteTagName('')
setDeleteTagConfirm('') setDetail(null); setSelectedTag(null)
setDeleteTagName('')
refreshTags() refreshTags()
setDetail(null)
setSelectedTag(null)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete tag' setDeleteError(err instanceof Error ? err.message : 'Failed to delete tag')
setDeleteError(message) } finally { setDeleting(false) }
} finally {
setDeleting(false)
}
} }
const handleDeleteImage = async () => { const handleDeleteImage = async () => {
if (!repoId) return if (!repoId) return
if (deleteImageConfirm.trim() !== deleteImageLabel) { if (deleteImageConfirm.trim() !== deleteImageLabel) { setDeleteError('Type the image name to confirm deletion.'); return }
setDeleteError('Type the image name to confirm deletion.')
return
}
setDeleteError(null) setDeleteError(null)
setDeleting(true) setDeleting(true)
try { try {
await api.deleteDockerImage(repoId, deleteImagePath) await api.deleteDockerImage(repoId, deleteImagePath)
setDeleteImageOpen(false) setDeleteImageOpen(false); setDeleteImageConfirm(''); setDeleteImageLabel('')
setDeleteImageConfirm('') if (path === deleteImagePath) enterFolder(parentPath(deleteImagePath))
setDeleteImagePath('') setDeleteImagePath('')
setDeleteImageLabel('')
setSelectedImage('')
setSelectedTag(null)
setDetail(null)
refreshImages() refreshImages()
refreshTags()
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete image' setDeleteError(err instanceof Error ? err.message : 'Failed to delete image')
setDeleteError(message) } finally { setDeleting(false) }
} finally {
setDeleting(false)
}
} }
const handleRenameTag = async () => { const handleRenameTag = async () => {
if (!repoId) return if (!repoId) return
if (!renameTagFrom.trim() || !renameTagTo.trim()) { if (!renameTagFrom.trim() || !renameTagTo.trim()) { setRenameError('Both tag names are required.'); return }
setRenameError('Both tag names are required.')
return
}
setRenameError(null) setRenameError(null)
setRenaming(true) setRenaming(true)
try { try {
await api.renameDockerTag(repoId, selectedImage, renameTagFrom.trim(), renameTagTo.trim()) await api.renameDockerTag(repoId, path, renameTagFrom.trim(), renameTagTo.trim())
setRenameTagOpen(false) setRenameTagOpen(false); setRenameTagFrom(''); setRenameTagTo('')
setRenameTagFrom('')
setRenameTagTo('')
refreshTags() refreshTags()
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename tag' setRenameError(err instanceof Error ? err.message : 'Failed to rename tag')
setRenameError(message) } finally { setRenaming(false) }
} finally {
setRenaming(false)
}
} }
const handleRenameImage = async () => { const handleRenameImage = async () => {
@@ -279,56 +260,141 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
setRenaming(true) setRenaming(true)
try { try {
await api.renameDockerImage(repoId, renameImageFrom, renameImageTo.trim()) await api.renameDockerImage(repoId, renameImageFrom, renameImageTo.trim())
setRenameImageOpen(false) setRenameImageOpen(false); setRenameImageFrom(''); setRenameImageFromLabel('')
setRenameImageFrom('') if (path === renameImageFrom) enterFolder(renameImageTo.trim())
setRenameImageFromLabel('')
setRenameImageTo('') setRenameImageTo('')
setSelectedImage(renameImageTo.trim())
refreshImages() refreshImages()
refreshTags()
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename image' setRenameError(err instanceof Error ? err.message : 'Failed to rename image')
setRenameError(message) } finally { setRenaming(false) }
} finally {
setRenaming(false)
}
} }
// Build a `docker pull` reference from the repo's registry URL, selected // registryBase = host[:port]/<project>/<repo>[/<image path>] — derived from the
// image, and selected tag. A pull reference is host[:port]/<repository>:<tag> // repo's docker_url with the scheme and the /v2 registry prefix stripped (the
// — it must NOT include the URL scheme or the registry's /v2 API prefix; the // docker client adds those itself). No tag.
// docker client adds /v2/ and /manifests/<tag> itself per the Registry HTTP const registryBase = (): string => {
// API V2 spec. docker_url is composed server-side as
// <PublicBaseURL>/v2/<project>/<repo>, so we strip both here.
const dockerPullRef = (): string => {
if (!repo?.docker_url) return '' if (!repo?.docker_url) return ''
const trimmed: string = repo.docker_url.trim() const trimmed: string = repo.docker_url.trim()
const absolute: string = trimmed.includes('://') const absolute: string = trimmed.includes('://')
? trimmed ? trimmed
: `${window.location.origin}${trimmed.startsWith('/') ? '' : '/'}${trimmed}` : `${window.location.origin}${trimmed.startsWith('/') ? '' : '/'}${trimmed}`
let base: string = absolute let base: string = absolute
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // drop scheme .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
.replace(/\/+$/, '') .replace(/\/+$/, '')
.replace(/^([^/]+)\/v2(?=\/|$)/, '$1') // drop the /v2 registry prefix after the host .replace(/^([^/]+)\/v2(?=\/|$)/, '$1')
if (selectedImage) base = `${base}/${selectedImage}` if (path) base = `${base}/${path}`
return `${base}:${selectedTag?.tag || 'latest'}` 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) => ( const renderCopyField = (value: string, label: string) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<TextField <TextField value={value} size="small" fullWidth InputProps={{ readOnly: true }} sx={{ '& input': { fontFamily: 'monospace', fontSize: 12 } }} />
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 }}> <IconButton size="small" onClick={() => copyText(value)} aria-label={`Copy ${label}`} sx={{ mt: 0.5 }}>
<ContentCopyIcon fontSize="small" /> <ContentCopyIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </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 ( return (
<ProjectPageFrame <ProjectPageFrame
projectId={projectId ?? ''} projectId={projectId ?? ''}
@@ -346,84 +412,70 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
<Divider sx={{ mb: 1 }} /> <Divider sx={{ mb: 1 }} />
<PageAlert message={loadError} onClose={() => setLoadError(null)} /> <PageAlert message={loadError} onClose={() => setLoadError(null)} />
<SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={( <SplitPanel defaultRatio={0.3} sx={{ mb: 1 }} left={(
<TintedPanel> <TintedPanel>
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} /> <PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
<List dense> <List dense>
{images.map((image) => ( {path !== '' ? (
<ListItem key={image || 'root'} disablePadding> <ListItem key="d:.." disablePadding>
<ListItemButton <ListItemButton onClick={() => enterFolder(parentPath(path))}>
onClick={() => { <FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
setSelectedImage(image) <ListItemText primary={<Typography variant="body2" sx={{ fontWeight: 300 }}>..</Typography>} />
setSelectedTag(null) </ListItemButton>
setDetail(null) </ListItem>
}} ) : null}
> {folders.map((folder) => (
<ListItemText <ListItem key={`d:${folder.path}`} disablePadding secondaryAction={folderActions(folder)}>
primary={ <ListItemButton onClick={() => enterFolder(folder.path)}>
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 400 : 300 }}> <FolderIcon fontSize="small" color="action" sx={{ mr: 1 }} />
{image || '(root)'} <ListItemText primary={<Typography variant="body2" sx={{ fontWeight: 300 }}>{folder.name}</Typography>} />
</Typography>
}
/>
</ListItemButton> </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> </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 }}> <Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
No images found. {currentIsImage ? 'No tags found.' : 'No images found.'}
</Typography> </Typography>
) : null} ) : null}
</List> </List>
</TintedPanel> </TintedPanel>
)} right={( )} right={(
<TintedPanel> <TintedPanel sx={{ ml:1 }}>
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, minHeight: 32 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, minHeight: 32 }}>
{selectedImage !== '' || images.includes('') ? ( {renderBreadcrumb()}
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
Image: {selectedImage || '(root)'}
</Typography>
) : null}
{repo?.docker_url ? ( {repo?.docker_url ? (
<Box sx={{ marginLeft: 'auto' }}> <Box sx={{ marginLeft: 'auto' }}>
<PillNavButton icon={<CodeIcon />} onClick={(event) => setUseAnchor(event.currentTarget)}> <PillNavButton icon={<CodeIcon />} onClick={(event) => setUseAnchor(event.currentTarget)}>Use</PillNavButton>
Use
</PillNavButton>
</Box> </Box>
) : null} ) : null}
</Box> </Box>
@@ -438,117 +490,187 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
<Typography variant="subtitle2">Pull this image</Typography> <Typography variant="subtitle2">Pull this image</Typography>
{renderCopyField(`docker pull ${dockerPullRef()}`, 'docker pull command')} {renderCopyField(`docker pull ${dockerPullRef()}`, 'docker pull command')}
<Typography variant="caption" color="text.secondary"> <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> </Typography>
</Box> </Box>
</Popover> </Popover>
<List dense> <Divider sx={{ mb: 1 }} />
{tags.map((tag) => ( <PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
<ListItem key={tag.tag} disablePadding>
<ListItemButton onClick={() => handleTagSelect(tag)}> {selectedTag ? (
<ListItemText <>
primary={ {detailLoading ? <Typography variant="body2" color="text.secondary">Loading manifest...</Typography> : null}
<Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 400 : 300 }}> <PageAlert severity="warning" message={detailError} onClose={() => setDetailError(null)} />
{tag.tag} {detail ? (() => {
</Typography> const cfg = detail.config || {}
} const platformLabel = (p: DockerPlatformInfo): string => `${p.os}/${p.architecture}${p.variant ? '/' + p.variant : ''}${p.os_version ? ' (' + p.os_version + ')' : ''}`
secondary={tag.digest ? tag.digest.slice(0, 12) : ''} 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() : []
</ListItemButton> const history = (cfg.history || []).filter((h) => h.created_by)
<span title={canWrite ? 'Rename tag' : 'You do not have permission to rename'}> return (
<IconButton <Box sx={{ display: 'grid', gap: 0.5 }}>
size="small" <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
disabled={!canWrite} <Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>Image: {imageRef()}</Typography>
onClick={() => { <Tooltip title="Copy image reference">
setRenameError(null) <IconButton size="small" onClick={() => copyText(imageRef())} aria-label="Copy image reference" sx={{ flexShrink: 0 }}><ContentCopyIcon fontSize="small" /></IconButton>
setRenameTagFrom(tag.tag) </Tooltip>
setRenameTagTo(tag.tag) </Box>
setRenameTagOpen(true) <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>
aria-label={`Rename tag ${tag.tag}`} <Tooltip title="Copy digest">
> <IconButton size="small" onClick={() => copyText(detail.digest)} aria-label="Copy digest" sx={{ flexShrink: 0 }}><ContentCopyIcon fontSize="small" /></IconButton>
<DriveFileRenameOutlineIcon fontSize="small" /> </Tooltip>
</IconButton> </Box>
</span> {metaRow('Media', <Typography variant="body2" sx={{ wordBreak: 'break-all' }}>{detail.media_type}</Typography>)}
<span title={canWrite ? 'Delete tag' : 'You do not have permission to delete'}> {metaRow('Size', detail.size ? formatBytes(detail.size) : 'n/a')}
<IconButton <Divider sx={{ my: 0.5 }} />
size="small" {metaRow('OS', `${cfg.os || 'n/a'}${cfg.os_version ? ' ' + cfg.os_version : ''}`)}
disabled={!canWrite} {metaRow('Architecture', cfg.architecture ? `${cfg.architecture}${cfg.variant ? '/' + cfg.variant : ''}` : 'n/a')}
onClick={() => { {metaRow('Created', cfg.created ? formatCommitTime(cfg.created) : 'n/a')}
setDeleteError(null) {cfg.author ? metaRow('Author', cfg.author) : null}
setDeleteTagName(tag.tag)
setDeleteTagConfirm('') {detail.platforms?.length ? (
setDeleteTagOpen(true) <>
}} <Divider sx={{ my: 0.5 }} />
aria-label={`Delete tag ${tag.tag}`} {sectionTitle(`Platforms (${detail.platforms.length})`)}
> <List dense>
<DeleteOutlineIcon fontSize="small" /> {detail.platforms.map((p) => (
</IconButton> <ListItem key={p.digest} disableGutters dense sx={{ py: 0 }} secondaryAction={
</span> <Tooltip title="Copy platform digest">
</ListItem> <IconButton edge="end" size="small" onClick={() => copyText(p.digest)} aria-label="Copy platform digest"><ContentCopyIcon fontSize="small" /></IconButton>
))} </Tooltip>
{!tags.length && !tagsError ? ( }>
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}> <ListItemText primary={
No tags found. <Box sx={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', gap: 2, minWidth: 0 }}>
</Typography> <Typography variant="body2" noWrap sx={{ fontFamily: 'monospace' }}>{platformLabel(p)}</Typography>
) : null} <Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>{p.size ? formatBytes(p.size) : ''}</Typography>
</List> </Box>
{detailLoading ? ( } />
<Typography variant="body2" color="text.secondary"> </ListItem>
Loading manifest... ))}
</Typography> </List>
) : null} </>
<PageAlert severity="warning" message={detailError} onClose={() => setDetailError(null)} /> ) : null}
{detail ? (
<Box sx={{ display: 'grid', gap: 0.5 }}> {hasRuntime ? (
<Typography variant="body2">Tag: {detail.reference}</Typography> <>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}> <Divider sx={{ my: 0.5 }} />
<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}> {sectionTitle('Configuration')}
Digest: {detail.digest} {cfg.entrypoint?.length ? metaRow('Entrypoint', monoLines([cfg.entrypoint.join(' ')])) : null}
</Typography> {cfg.cmd?.length ? metaRow('Cmd', monoLines([cfg.cmd.join(' ')])) : null}
<Tooltip title="Copy digest"> {cfg.working_dir ? metaRow('Workdir', <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{cfg.working_dir}</Typography>) : null}
<IconButton size="small" onClick={() => copyText(detail.digest)} aria-label="Copy digest" sx={{ flexShrink: 0 }}> {cfg.user ? metaRow('User', <Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>{cfg.user}</Typography>) : null}
<ContentCopyIcon fontSize="small" /> {cfg.exposed_ports?.length ? metaRow('Ports', (
</IconButton> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
</Tooltip> {cfg.exposed_ports.map((port) => <Chip key={port} label={port} size="small" variant="outlined" />)}
</Box> </Box>
<Typography variant="body2">Media: {detail.media_type}</Typography> )) : null}
<Typography variant="body2">Size: {detail.size} bytes</Typography> {cfg.volumes?.length ? metaRow('Volumes', monoLines(cfg.volumes)) : null}
<Divider sx={{ my: 0.5 }} /> {cfg.stop_signal ? metaRow('Stop signal', cfg.stop_signal) : null}
<Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography> {cfg.env?.length ? metaRow('Env', monoLines(cfg.env)) : null}
<Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography> </>
<Typography variant="body2"> ) : null}
Created: {detail.config?.created ? formatCommitTime(detail.config.created) : 'n/a'}
</Typography> {labelKeys.length ? (
<Divider sx={{ my: 0.5 }} /> <>
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography> <Divider sx={{ my: 0.5 }} />
<List dense> {sectionTitle(`Labels (${labelKeys.length})`)}
{detail.layers?.map((layer) => ( <Box sx={{ display: 'grid', gap: 0.25, minWidth: 0 }}>
<ListItem {labelKeys.map((k) => (
key={layer.digest} <Box key={k} sx={{ display: 'grid', gridTemplateColumns: 'minmax(120px, 38%) minmax(0, 1fr)', gap: 1, minWidth: 0 }}>
secondaryAction={ <Typography variant="caption" sx={{ fontFamily: 'monospace', color: 'text.secondary', wordBreak: 'break-all' }}>{k}</Typography>
<Tooltip title="Copy layer digest"> <Typography variant="caption" sx={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>{cfg.labels?.[k] ?? ''}</Typography>
<IconButton edge="end" size="small" onClick={() => copyText(layer.digest)} aria-label="Copy layer digest"> </Box>
<ContentCopyIcon fontSize="small" /> ))}
</IconButton> </Box>
</Tooltip> </>
} ) : null}
>
<ListItemText <Divider sx={{ my: 0.5 }} />
primary={<Typography variant="body2" sx={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{layer.digest}</Typography>} {sectionTitle(`Layers (${detail.layers?.length || 0})`)}
secondary={`${layer.size} bytes`} <List dense>
/> {detail.layers?.map((layer) => (
</ListItem> <ListItem key={layer.digest} disableGutters dense sx={{ py: 0 }} secondaryAction={
))} <Tooltip title="Copy layer digest">
</List> <IconButton edge="end" size="small" onClick={() => copyText(layer.digest)} aria-label="Copy layer digest"><ContentCopyIcon fontSize="small" /></IconButton>
</Box> </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"> <List dense>
Select a tag to view details. {path !== '' ? (
</Typography> <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> </TintedPanel>
)} /> )} />
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={deleteTagOpen} open={deleteTagOpen}
title="Delete tag" title="Delete tag"
@@ -562,9 +684,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
onConfirm={handleDeleteTag} onConfirm={handleDeleteTag}
onClose={() => setDeleteTagOpen(false)} onClose={() => setDeleteTagOpen(false)}
> >
<Typography variant="body2"> <Typography variant="body2">Delete tag "{deleteTagName}"?</Typography>
Delete tag "{deleteTagName}"?
</Typography>
</ConfirmDeleteDialog> </ConfirmDeleteDialog>
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={deleteImageOpen} open={deleteImageOpen}
@@ -579,9 +699,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
onConfirm={handleDeleteImage} onConfirm={handleDeleteImage}
onClose={() => setDeleteImageOpen(false)} onClose={() => setDeleteImageOpen(false)}
> >
<Typography variant="body2"> <Typography variant="body2">Delete image "{deleteImageLabel}" and all its tags?</Typography>
Delete image "{deleteImageLabel || '(root)'}" and all tags?
</Typography>
</ConfirmDeleteDialog> </ConfirmDeleteDialog>
<DockerRenameDialog <DockerRenameDialog
open={renameTagOpen} open={renameTagOpen}
@@ -601,7 +719,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
from={renameImageFromLabel} from={renameImageFromLabel}
to={renameImageTo} to={renameImageTo}
onToChange={setRenameImageTo} onToChange={setRenameImageTo}
toHelperText="Leave blank to move to root." toHelperText="Full image path; leave blank to move to root."
busy={renaming} busy={renaming}
onClose={() => setRenameImageOpen(false)} onClose={() => setRenameImageOpen(false)}
onRename={handleRenameImage} onRename={handleRenameImage}