Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2094037a5d | |||
| a5ef2b51a8 | |||
| 19e6bd190c |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 || ' ' }}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={`language-${language}`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user