Deletion and more details
the build was successful
Details
the build was successful
Details
parent
f9abc53a30
commit
1649afe535
|
@ -5,6 +5,7 @@ BASE_URL=${BASE_URL:=/}
|
||||||
REGISTRY_HOST=${REGISTRY_HOST:=""}
|
REGISTRY_HOST=${REGISTRY_HOST:=""}
|
||||||
REGISTRY_API=${REGISTRY_API:=""}
|
REGISTRY_API=${REGISTRY_API:=""}
|
||||||
|
|
||||||
|
[[ "$DELETE_ENABLED" != "true" ]] && [[ "$DELETE_ENABLED" != "false" ]] && DELETE_ENABLED=false
|
||||||
REPOSITORIES_PER_PAGE=${REPOSITORIES_PER_PAGE:=0}
|
REPOSITORIES_PER_PAGE=${REPOSITORIES_PER_PAGE:=0}
|
||||||
TAGS_PER_PAGE=${TAGS_PER_PAGE:=30}
|
TAGS_PER_PAGE=${TAGS_PER_PAGE:=30}
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@ cat > /srv/config.json << EOF
|
||||||
{
|
{
|
||||||
"registryHost": "$REGISTRY_HOST",
|
"registryHost": "$REGISTRY_HOST",
|
||||||
"registryAPI": "$REGISTRY_API",
|
"registryAPI": "$REGISTRY_API",
|
||||||
|
"deleteEnabled": $DELETE_ENABLED,
|
||||||
"repositoriesPerPage": $REPOSITORIES_PER_PAGE,
|
"repositoriesPerPage": $REPOSITORIES_PER_PAGE,
|
||||||
"tagsPerPage": $TAGS_PER_PAGE,
|
"tagsPerPage": $TAGS_PER_PAGE,
|
||||||
"usePortusExplore": $USE_PORTUS_EXPLORE
|
"usePortusExplore": $USE_PORTUS_EXPLORE
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
version: 0.1
|
||||||
|
log:
|
||||||
|
fields:
|
||||||
|
service: registry
|
||||||
|
storage:
|
||||||
|
cache:
|
||||||
|
blobdescriptor: inmemory
|
||||||
|
filesystem:
|
||||||
|
rootdirectory: /var/lib/registry
|
||||||
|
delete:
|
||||||
|
enabled: true
|
||||||
|
http:
|
||||||
|
addr: :5000
|
||||||
|
headers:
|
||||||
|
X-Content-Type-Options: [nosniff]
|
||||||
|
Access-Control-Allow-Origin: ['*']
|
||||||
|
Access-Control-Allow-Methods: ['GET, DELETE']
|
||||||
|
Access-Control-Allow-Headers: ['Authorization']
|
||||||
|
Access-Control-Expose-Headers: ['Content-Length, Www-Authenticate', 'Docker-Content-Digest']
|
||||||
|
health:
|
||||||
|
storagedriver:
|
||||||
|
enabled: true
|
||||||
|
interval: 10s
|
||||||
|
threshold: 3
|
98
src/api.js
98
src/api.js
|
@ -1,6 +1,6 @@
|
||||||
import parseLink from 'parse-link-header';
|
import parseLink from 'parse-link-header';
|
||||||
|
|
||||||
import { registryAPI, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options';
|
import { registryAPI, deleteEnabled, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options';
|
||||||
|
|
||||||
function parseWWWAuthenticate(text) {
|
function parseWWWAuthenticate(text) {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
@ -86,29 +86,41 @@ async function paginatable(path, scope, n, last = null) {
|
||||||
return Object.assign(await response.json(), { nextLast });
|
return Object.assign(await response.json(), { nextLast });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(path, scope) {
|
async function request(method, path, scope, accept) {
|
||||||
const url = new URL(`${await registryAPI()}${path}`);
|
const url = new URL(`${await registryAPI()}${path}`);
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
if (accept) {
|
||||||
|
headers.Accept = accept;
|
||||||
|
}
|
||||||
if (scope) {
|
if (scope) {
|
||||||
const token = await doAuth(scope);
|
const token = await doAuth(scope);
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
const response = await fetch(url, { headers });
|
const response = await fetch(url, { method, headers });
|
||||||
return response.json();
|
if (!response.ok) {
|
||||||
}
|
if (method === 'HEAD') {
|
||||||
|
throw new Error(`${response.statusText}`);
|
||||||
async function head(path, scope) {
|
} else if (response.headers.get('Content-Type').startsWith('application/json')) {
|
||||||
const url = new URL(`${await registryAPI()}${path}`);
|
const r = await response.json();
|
||||||
const headers = {};
|
const firstError = r.errors[0];
|
||||||
if (scope) {
|
if (firstError) {
|
||||||
const token = await doAuth(scope);
|
throw new Error(`${firstError.code}: ${firstError.message}`);
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`${response.statusText}`);
|
||||||
}
|
}
|
||||||
const response = await fetch(url, { method: 'HEAD', headers });
|
if (!response.headers.get('Content-Type').startsWith('application/json')) {
|
||||||
return response.headers;
|
console.warn('response returned was not JSON, parsing may fail');
|
||||||
|
}
|
||||||
|
if (method === 'HEAD' || parseInt(response.headers.get('Content-Length'), 10) < 1) {
|
||||||
|
return { headers: response.headers };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(await response.json()),
|
||||||
|
headers: response.headers,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function portus() {
|
async function portus() {
|
||||||
// TODO: Use the Portus API when it enables anonymous access
|
// TODO: Use the Portus API when it enables anonymous access
|
||||||
const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`);
|
const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`);
|
||||||
|
@ -135,10 +147,6 @@ async function repos(last = null) {
|
||||||
return paginatable('/v2/_catalog', null, await repositoriesPerPage(), last);
|
return paginatable('/v2/_catalog', null, await repositoriesPerPage(), last);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function repo(name) {
|
|
||||||
return get(`/v2/${name}`, `repository:${name}:pull`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tags(name, last = null) {
|
async function tags(name, last = null) {
|
||||||
if (await usePortusExplore()) {
|
if (await usePortusExplore()) {
|
||||||
const p = await portus();
|
const p = await portus();
|
||||||
|
@ -151,20 +159,66 @@ async function tags(name, last = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tag(name, ref) {
|
async function tag(name, ref) {
|
||||||
return get(`/v2/${name}/manifests/${ref}`, `repository:${name}:pull`);
|
return request('GET', `/v2/${name}/manifests/${ref}`, `repository:${name}:pull`, 'application/vnd.docker.distribution.manifest.v2+json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagCanDelete(name, ref) {
|
||||||
|
if (!await deleteEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { headers } = await request('HEAD', `/v2/${name}/manifests/${ref}`, `repository:${name}:delete`, 'application/vnd.docker.distribution.manifest.v2+json');
|
||||||
|
request('HEAD', `/v2/${name}/manifests/${headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagDelete(name, ref) {
|
||||||
|
const tagManifest = await tag(name, ref);
|
||||||
|
// delete each blob
|
||||||
|
// await Promise.all(tagManifest.layers.map(l =>
|
||||||
|
// request('DELETE', `/v2/${name}/blobs/${l.digest}`, `repository:${name}:delete`)));
|
||||||
|
return request('DELETE', `/v2/${name}/manifests/${tagManifest.headers.get('Docker-Content-Digest')}`, `repository:${name}:delete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repoCanDelete(name) {
|
||||||
|
if (!await deleteEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`);
|
||||||
|
if (!r.tags) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Promise.race(r.tags.map(t => tagCanDelete(name, t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repoDelete(name) {
|
||||||
|
const r = await request('GET', `/v2/${name}/tags/list`, `repository:${name}:delete`);
|
||||||
|
return Promise.all(r.tags.map(t => tagDelete(name, t)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function blob(name, digest) {
|
async function blob(name, digest) {
|
||||||
const headers = await head(`/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
|
const { headers } = await request('HEAD', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
|
||||||
return {
|
return {
|
||||||
|
dockerContentDigest: headers.get('Docker-Content-Digest'),
|
||||||
contentLength: parseInt(headers.get('Content-Length'), 10),
|
contentLength: parseInt(headers.get('Content-Length'), 10),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function configBlob(name, digest) {
|
||||||
|
return request('GET', `/v2/${name}/blobs/${digest}`, `repository:${name}:pull`);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
repos,
|
repos,
|
||||||
repo,
|
|
||||||
tags,
|
tags,
|
||||||
tag,
|
tag,
|
||||||
|
tagCanDelete,
|
||||||
|
tagDelete,
|
||||||
|
repoCanDelete,
|
||||||
|
repoDelete,
|
||||||
blob,
|
blob,
|
||||||
|
configBlob,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<LoadableText :text="size" />
|
<LoadableText :text="text" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -14,15 +14,22 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
repo: String,
|
repo: String,
|
||||||
blob: String,
|
blob: String,
|
||||||
|
size: Number,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
size: '',
|
text: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
if (this.size) {
|
||||||
|
this.text = filesize(this.size);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const size = await blob(this.repo, this.blob);
|
const size = await blob(this.repo, this.blob);
|
||||||
this.size = filesize(size.contentLength);
|
if (size.contentLength) {
|
||||||
|
this.text = filesize(size.contentLength);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -40,6 +40,8 @@ header, footer, main {
|
||||||
header, footer, .content {
|
header, footer, .content {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
.content {
|
||||||
|
width: fit-content;}
|
||||||
header, footer {
|
header, footer {
|
||||||
max-width: 38rem;
|
max-width: 38rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<LoadableText :text="size" />
|
<LoadableText :text="text" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import filesize from 'filesize';
|
import filesize from 'filesize';
|
||||||
import { tag, blob } from '@/api';
|
import { tag } from '@/api';
|
||||||
import LoadableText from '@/components/LoadableText.vue';
|
import LoadableText from '@/components/LoadableText.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -14,10 +14,11 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
repo: String,
|
repo: String,
|
||||||
tag: String,
|
tag: String,
|
||||||
|
size: Number,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
size: '',
|
text: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
|
@ -27,13 +28,11 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (r.schemaVersion === 1) {
|
if (r.schemaVersion === 1) {
|
||||||
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
|
console.error('V1 manifests not supported');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const sizes = await Promise.all(r.layers.map(async layer => (
|
const total = r.layers.reduce((a, b) => a + b.size, 0);
|
||||||
await blob(this.repo, layer.digest)
|
this.text = filesize(total);
|
||||||
).contentLength));
|
|
||||||
const total = sizes.reduce((a, b) => a + b, 0);
|
|
||||||
this.size = filesize(total);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,3 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<span>No Toolbar</span>
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<button :class="{ danger }" @click="$emit('click', $event)">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
danger: Boolean,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #66f;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #44f;
|
||||||
|
}
|
||||||
|
button:active {
|
||||||
|
background: #00f;
|
||||||
|
}
|
||||||
|
button:focus {
|
||||||
|
box-shadow: 0 0 0 0.25rem #ccf;
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
background: #f66;
|
||||||
|
}
|
||||||
|
button.danger:hover {
|
||||||
|
background: #f44;
|
||||||
|
}
|
||||||
|
button.danger:active {
|
||||||
|
background: #f00;
|
||||||
|
}
|
||||||
|
button.danger:focus {
|
||||||
|
box-shadow: 0 0 0 0.25rem #fcc;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -7,10 +7,11 @@ const defaultConfig = {
|
||||||
registryHost: process.env.VUE_APP_REGISTRY_HOST,
|
registryHost: process.env.VUE_APP_REGISTRY_HOST,
|
||||||
registryAPI: process.env.VUE_APP_REGISTRY_API,
|
registryAPI: process.env.VUE_APP_REGISTRY_API,
|
||||||
|
|
||||||
repositoriesPerPage: process.env.VUE_APP_REPOSITORIES_PER_PAGE,
|
deleteEnabled: process.env.VUE_APP_DELETE_ENABLED === 'true',
|
||||||
tagsPerPage: process.env.VUE_APP_TAGS_PER_PAGE,
|
repositoriesPerPage: parseInt(process.env.VUE_APP_REPOSITORIES_PER_PAGE, 10),
|
||||||
|
tagsPerPage: parseInt(process.env.VUE_APP_TAGS_PER_PAGE, 10),
|
||||||
|
|
||||||
usePortusExplore: process.env.VUE_APP_USE_PORTUS_EXPLORE,
|
usePortusExplore: process.env.VUE_APP_USE_PORTUS_EXPLORE === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function config() {
|
async function config() {
|
||||||
|
@ -49,9 +50,9 @@ async function registryAPI() {
|
||||||
return `${window.location.protocol}//${host}`;
|
return `${window.location.protocol}//${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usePortusExplore() {
|
async function deleteEnabled() {
|
||||||
const c = await config();
|
const c = await config();
|
||||||
if (c.usePortusExplore) {
|
if (c.deleteEnabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -79,11 +80,20 @@ async function tagsPerPage() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function usePortusExplore() {
|
||||||
|
const c = await config();
|
||||||
|
if (c.usePortusExplore) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
version,
|
version,
|
||||||
source,
|
source,
|
||||||
registryHost,
|
registryHost,
|
||||||
registryAPI,
|
registryAPI,
|
||||||
|
deleteEnabled,
|
||||||
repositoriesPerPage,
|
repositoriesPerPage,
|
||||||
tagsPerPage,
|
tagsPerPage,
|
||||||
usePortusExplore,
|
usePortusExplore,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<Layout>
|
<Layout>
|
||||||
<h1 slot="title">{{ $route.params.repo }}</h1>
|
<h1 slot="title">{{ $route.params.repo }}</h1>
|
||||||
|
<Error slot="error" :message='error' />
|
||||||
<h2>Details</h2>
|
<h2>Details</h2>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
|
@ -16,7 +17,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { blob } from '@/api';
|
||||||
|
|
||||||
import Layout from '@/components/Layout.vue';
|
import Layout from '@/components/Layout.vue';
|
||||||
|
import Error from '@/components/Error.vue';
|
||||||
import List from '@/components/List.vue';
|
import List from '@/components/List.vue';
|
||||||
import ListItem from '@/components/ListItem.vue';
|
import ListItem from '@/components/ListItem.vue';
|
||||||
import BlobSize from '@/components/BlobSize.vue';
|
import BlobSize from '@/components/BlobSize.vue';
|
||||||
|
@ -24,9 +28,37 @@ import BlobSize from '@/components/BlobSize.vue';
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Layout,
|
Layout,
|
||||||
|
Error,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
BlobSize,
|
BlobSize,
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
error: '',
|
||||||
|
digest: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
await this.fetchBlob();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchBlob() {
|
||||||
|
try {
|
||||||
|
const r = await blob(this.$route.params.repo, this.$route.params.digest);
|
||||||
|
if (r.dockerContentDigest) {
|
||||||
|
this.digest = r.dockerContentDigest;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.error = `Unable to fetch blob (${e.message})`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async $route() {
|
||||||
|
await this.fetchBlob();
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
<Layout>
|
<Layout>
|
||||||
<h1 slot="title">{{ $route.params.repo }}</h1>
|
<h1 slot="title">{{ $route.params.repo }}</h1>
|
||||||
<Error slot="error" :message='error' />
|
<Error slot="error" :message='error' />
|
||||||
|
<Toolbar slot="toolbar">
|
||||||
|
<ToolbarButton v-if="repoCanDelete" @click="deleteRepo" danger>Delete</ToolbarButton>
|
||||||
|
</Toolbar>
|
||||||
<List>
|
<List>
|
||||||
<ListHeader slot="header">
|
<ListHeader slot="header">
|
||||||
<span slot="title">Tag</span>
|
<span slot="title">Tag</span>
|
||||||
|
@ -27,11 +30,12 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { registryHost } from '@/options';
|
import { registryHost } from '@/options';
|
||||||
import { tags } from '@/api';
|
import { tags, repoCanDelete, repoDelete } from '@/api';
|
||||||
|
|
||||||
import Layout from '@/components/Layout.vue';
|
import Layout from '@/components/Layout.vue';
|
||||||
import Error from '@/components/Error.vue';
|
import Error from '@/components/Error.vue';
|
||||||
import Toolbar from '@/components/Toolbar.vue';
|
import Toolbar from '@/components/Toolbar.vue';
|
||||||
|
import ToolbarButton from '@/components/ToolbarButton.vue';
|
||||||
import List from '@/components/List.vue';
|
import List from '@/components/List.vue';
|
||||||
import ListHeader from '@/components/ListHeader.vue';
|
import ListHeader from '@/components/ListHeader.vue';
|
||||||
import ListItem from '@/components/ListItem.vue';
|
import ListItem from '@/components/ListItem.vue';
|
||||||
|
@ -43,6 +47,7 @@ export default {
|
||||||
Layout,
|
Layout,
|
||||||
Error,
|
Error,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
|
ToolbarButton,
|
||||||
List,
|
List,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
@ -54,6 +59,7 @@ export default {
|
||||||
error: '',
|
error: '',
|
||||||
registryHost: '',
|
registryHost: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
|
repoCanDelete: false,
|
||||||
nextLast: '',
|
nextLast: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -62,14 +68,24 @@ export default {
|
||||||
await this.fetchTags();
|
await this.fetchTags();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async deleteRepo() {
|
||||||
|
try {
|
||||||
|
await repoDelete(this.$route.params.repo);
|
||||||
|
this.$router.push({ name: 'repos' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.error = `Unable to delete repo (${e.message})`;
|
||||||
|
}
|
||||||
|
},
|
||||||
async fetchTags() {
|
async fetchTags() {
|
||||||
try {
|
try {
|
||||||
const r = await tags(this.$route.params.repo, this.$route.query.last);
|
const r = await tags(this.$route.params.repo, this.$route.query.last);
|
||||||
this.tags = r.tags;
|
this.tags = r.tags;
|
||||||
this.nextLast = r.nextLast;
|
this.nextLast = r.nextLast;
|
||||||
|
this.repoCanDelete = await repoCanDelete(this.$route.params.repo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.error = `Unable to fetch tags (${e.name})`;
|
this.error = `Unable to fetch tags (${e.message})`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -65,7 +65,7 @@ export default {
|
||||||
this.nextLast = r.nextLast;
|
this.nextLast = r.nextLast;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.error = `Unable to fetch repositories (${e.name})`;
|
this.error = `Unable to fetch repositories (${e.message})`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
<Layout>
|
<Layout>
|
||||||
<h1 slot="title">{{ $route.params.repo }}:{{ $route.params.tag }}</h1>
|
<h1 slot="title">{{ $route.params.repo }}:{{ $route.params.tag }}</h1>
|
||||||
<Error slot="error" :message='error' />
|
<Error slot="error" :message='error' />
|
||||||
|
<Toolbar slot="toolbar">
|
||||||
|
<ToolbarButton v-if="tagCanDelete" @click="deleteTag" danger>Delete</ToolbarButton> </Toolbar>
|
||||||
<h2>Details</h2>
|
<h2>Details</h2>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
|
@ -9,8 +11,32 @@
|
||||||
<span slot="detail">{{ tag.schemaVersion }}</span>
|
<span slot="detail">{{ tag.schemaVersion }}</span>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<span slot="title">Architecture</span>
|
<span slot="title">Full Digest</span>
|
||||||
<span slot="detail">{{ tag.architecture }}</span>
|
<span slot="detail">{{ tag.headers.get('Docker-Content-Digest') }}</span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Date Created</span>
|
||||||
|
<span slot="detail">{{ config.created }}</span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Platform</span>
|
||||||
|
<span slot="detail">{{ config.os }} {{ config.architecture }}</span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Entrypoint</span>
|
||||||
|
<span slot="detail"><pre>{{ config.config.Entrypoint.join(' ') }}</pre></span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Command</span>
|
||||||
|
<span slot="detail"><pre>{{ config.config.Cmd.join(' ') }}</pre></span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Labels</span>
|
||||||
|
<span slot="detail"><pre>{{ formatLabels(config.config.Labels) }}</pre></span>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem>
|
||||||
|
<span slot="title">Layers</span>
|
||||||
|
<span slot="detail">{{ tag.layers.length }}</span>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<span slot="title">Size</span>
|
<span slot="title">Size</span>
|
||||||
|
@ -32,7 +58,7 @@
|
||||||
:to="{ name: 'blob', params: { repo: $route.params.repo, digest: layer.digest }}">
|
:to="{ name: 'blob', params: { repo: $route.params.repo, digest: layer.digest }}">
|
||||||
<span slot="title" :title="layer.digest">{{ identifier(tag, i) }}</span>
|
<span slot="title" :title="layer.digest">{{ identifier(tag, i) }}</span>
|
||||||
<span slot="detail">{{ command(tag, i) }}</span>
|
<span slot="detail">{{ command(tag, i) }}</span>
|
||||||
<BlobSize slot="size" :repo="$route.params.repo" :blob="layer.digest" />
|
<BlobSize slot="size" :size="layer.size" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -40,11 +66,12 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { registryHost } from '@/options';
|
import { registryHost } from '@/options';
|
||||||
import { tag } from '@/api';
|
import { tag, tagCanDelete, tagDelete, configBlob } from '@/api';
|
||||||
|
|
||||||
import Layout from '@/components/Layout.vue';
|
import Layout from '@/components/Layout.vue';
|
||||||
import Error from '@/components/Error.vue';
|
import Error from '@/components/Error.vue';
|
||||||
import Toolbar from '@/components/Toolbar.vue';
|
import Toolbar from '@/components/Toolbar.vue';
|
||||||
|
import ToolbarButton from '@/components/ToolbarButton.vue';
|
||||||
import List from '@/components/List.vue';
|
import List from '@/components/List.vue';
|
||||||
import ListHeader from '@/components/ListHeader.vue';
|
import ListHeader from '@/components/ListHeader.vue';
|
||||||
import ListItem from '@/components/ListItem.vue';
|
import ListItem from '@/components/ListItem.vue';
|
||||||
|
@ -56,6 +83,7 @@ export default {
|
||||||
Layout,
|
Layout,
|
||||||
Error,
|
Error,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
|
ToolbarButton,
|
||||||
List,
|
List,
|
||||||
ListHeader,
|
ListHeader,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
@ -66,7 +94,9 @@ export default {
|
||||||
return {
|
return {
|
||||||
error: '',
|
error: '',
|
||||||
registryHost: '',
|
registryHost: '',
|
||||||
tag: {},
|
tag: { },
|
||||||
|
config: {},
|
||||||
|
tagCanDelete: false,
|
||||||
layers: [],
|
layers: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -75,20 +105,36 @@ export default {
|
||||||
await this.fetchTag();
|
await this.fetchTag();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async deleteTag() {
|
||||||
|
try {
|
||||||
|
await tagDelete(this.$route.params.repo, this.$route.params.tag);
|
||||||
|
this.$router.push({ name: 'repo', params: { repo: this.$route.params.repo } });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.error = `Unable to delete tag (${e.message})`;
|
||||||
|
}
|
||||||
|
},
|
||||||
async fetchTag() {
|
async fetchTag() {
|
||||||
try {
|
try {
|
||||||
const r = await tag(this.$route.params.repo, this.$route.params.tag);
|
const r = await tag(this.$route.params.repo, this.$route.params.tag);
|
||||||
if (r.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
|
if (r.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
|
||||||
this.error = 'V2 manifest lists not supported yet';
|
this.error = 'V2 manifest lists not supported yet';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (r.schemaVersion === 1) {
|
if (r.schemaVersion === 1) {
|
||||||
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
|
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
|
||||||
}
|
}
|
||||||
this.tag = r;
|
this.tag = r;
|
||||||
console.log(r);
|
this.tagCanDelete = await tagCanDelete(this.$route.params.repo, this.$route.params.tag);
|
||||||
|
|
||||||
|
// extract configuration
|
||||||
|
if (r.config.mediaType !== 'application/vnd.docker.container.image.v1+json') {
|
||||||
|
this.error = 'configuration mediaType not supported';
|
||||||
|
}
|
||||||
|
this.config = await configBlob(this.$route.params.repo, r.config.digest);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.error = `Unable to fetch tag (${e.name})`;
|
this.error = `Unable to fetch tag (${e.message})`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
identifier(t, n) {
|
identifier(t, n) {
|
||||||
|
@ -97,6 +143,9 @@ export default {
|
||||||
command() {
|
command() {
|
||||||
return 'not implemented';
|
return 'not implemented';
|
||||||
},
|
},
|
||||||
|
formatLabels(l) {
|
||||||
|
return Object.keys(l).map(k => `${k}: ${l[k]}`).join('\n');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
async $route() {
|
async $route() {
|
||||||
|
|
Loading…
Reference in New Issue