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