Browse Source

Initial commit

pull/2/head
Ambrose Chua 3 years ago
commit
08f396a812
  1. 3
      .browserslistrc
  2. 17
      .eslintrc.js
  3. 21
      .gitignore
  4. 12
      README.md
  5. 5
      babel.config.js
  6. 24
      package.json
  7. 5
      postcss.config.js
  8. BIN
      public/favicon.ico
  9. 19
      public/index.html
  10. 24
      src/App.vue
  11. 77
      src/api.js
  12. 28
      src/components/BlobSize.vue
  13. 22
      src/components/Error.vue
  14. 52
      src/components/Layout.vue
  15. 17
      src/components/List.vue
  16. 23
      src/components/ListHeader.vue
  17. 36
      src/components/ListItem.vue
  18. 46
      src/components/LoadableText.vue
  19. 34
      src/components/Paginator.vue
  20. 39
      src/components/TagSize.vue
  21. 3
      src/components/Toolbar.vue
  22. 12
      src/main.js
  23. 90
      src/options.js
  24. 35
      src/router.js
  25. 32
      src/views/Blob.vue
  26. 82
      src/views/Repo.vue
  27. 78
      src/views/Repos.vue
  28. 105
      src/views/Tag.vue
  29. 3
      vue.config.js
  30. 6908
      yarn.lock

3
.browserslistrc

@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

17
.eslintrc.js

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/essential',
'@vue/airbnb',
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
parserOptions: {
parser: 'babel-eslint',
},
};

21
.gitignore

@ -0,0 +1,21 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

12
README.md

@ -0,0 +1,12 @@
# dri
A static Docker Distribution Registry viewer.
## Features
* View repositories and tags
* View tag details and sizes of tags
* Delete repositories and tags (if enabled on the registry)
* Optionally scan for vulnerabilities using CoreOS Clair

5
babel.config.js

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app',
],
};

24
package.json

@ -0,0 +1,24 @@
{
"name": "dri",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"filesize": "^3.6.1",
"normalize.css": "^8.0.0",
"parse-link-header": "^1.0.1",
"vue": "^2.5.17",
"vue-router": "^3.0.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.0.3",
"@vue/cli-plugin-eslint": "^3.0.3",
"@vue/cli-service": "^3.0.3",
"@vue/eslint-config-airbnb": "^3.0.3",
"vue-template-compiler": "^2.5.17"
}
}

5
postcss.config.js

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

BIN
public/favicon.ico

19
public/index.html

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<base href="<%= BASE_URL %>">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Code+Pro|Source+Sans+Pro">
<title>dri</title>
</head>
<body>
<noscript>
<strong>We're sorry but dri doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

24
src/App.vue

@ -0,0 +1,24 @@
<template>
<router-view/>
</template>
<style>
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
padding: 0;
margin: 0;
}
html {
font-size: 16px;
}
body {
font-size: 1rem;
font-family: 'Source Sans Pro';
}
pre, code, kbd, samp {
font-family: 'Source Code Pro';
}
</style>

77
src/api.js

@ -0,0 +1,77 @@
import parseLink from 'parse-link-header';
import { registryAPI, repositoriesPerPage, tagsPerPage, usePortusExplore } from '@/options';
async function paginatable(path, n, last = null) {
const url = new URL(`${await registryAPI()}${path}`);
if (n) url.searchParams.append('n', n);
if (last) url.searchParams.append('last', last);
const response = await fetch(url);
let nextLast = null;
if (response.headers.has('Link')) {
const links = parseLink(response.headers.get('Link'));
if (links && links.next) {
nextLast = links.next.last;
}
}
return Object.assign(await response.json(), { nextLast });
}
async function get(path) {
const url = new URL(`${await registryAPI()}${path}`);
const response = await fetch(url);
return response.json();
}
async function head(path) {
const url = new URL(`${await registryAPI()}${path}`);
const response = await fetch(url, { method: 'HEAD' });
return response.headers;
}
async function repos(last = null) {
if (await usePortusExplore()) {
// TODO: Use the Portus API when it enables anonymous access
const response = await fetch(`${await registryAPI()}/explore?explore%5Bsearch%5D=`);
const html = await response.text();
// unconventionally parse out JSON from HTML
const startString = 'window.repositories = ';
const endString = ';</script>';
const string = html.substring(
html.lastIndexOf(startString) + startString.length,
html.indexOf(endString),
);
return JSON.parse(string);
}
return paginatable('/v2/_catalog', await repositoriesPerPage(), last);
}
async function repo(name) {
return get(`/v2/${name}`);
}
async function tags(name, last = null) {
return paginatable(`/v2/${name}/tags/list`, await tagsPerPage(), last);
}
async function tag(name, ref) {
return get(`/v2/${name}/manifests/${ref}`);
}
async function blob(name, digest) {
const headers = await head(`/v2/${name}/blobs/${digest}`);
return {
contentLength: parseInt(headers.get('Content-Length'), 10),
};
}
export {
repos,
repo,
tags,
tag,
blob,
};

28
src/components/BlobSize.vue

@ -0,0 +1,28 @@
<template>
<LoadableText :text="size" />
</template>
<script>
import filesize from 'filesize';
import { blob } from '@/api';
import LoadableText from '@/components/LoadableText.vue';
export default {
components: {
LoadableText,
},
props: {
repo: String,
blob: String,
},
data() {
return {
size: '',
};
},
async created() {
const size = await blob(this.repo, this.blob);
this.size = filesize(size.contentLength);
},
};
</script>

22
src/components/Error.vue

@ -0,0 +1,22 @@
<template>
<div v-if="message">{{ message }}</div>
</template>
<script>
export default {
props: {
message: String,
},
};
</script>
<style scoped>
div {
text-align: center;
background: #fdd;
color: #800;
padding: 0.5rem 0.75rem;
margin: 1rem;
border-radius: 0.5rem;
}
</style>

52
src/components/Layout.vue

@ -0,0 +1,52 @@
<template>
<div class="wrapper">
<header>
<slot name="title" />
<slot name="error" />
<slot name="toolbar" />
</header>
<main>
<div class="content">
<slot />
</div>
</main>
<footer>
<p>
<a :href="source">dri</a>
{{ version }}
by <a href="https://github.com/serverwentdown">@serverwentdown</a>
</p>
</footer>
</div>
</template>
<script>
import { version, source } from '@/options';
export default {
data() {
return {
version,
source,
};
},
};
</script>
<style scoped>
header, footer, main {
margin: 0 auto;
}
header, footer, .content {
padding: 1rem;
}
header, footer {
max-width: 38rem;
text-align: center;
}
main {
width: fit-content;
max-width: 100%;
overflow-x: auto;
}
</style>

17
src/components/List.vue

@ -0,0 +1,17 @@
<template>
<table>
<thead>
<slot name="header" />
</thead>
<tbody>
<slot />
</tbody>
</table>
</template>
<style scoped>
table {
min-width: 28rem;
border-spacing: 0;
}
</style>

23
src/components/ListHeader.vue

@ -0,0 +1,23 @@
<template>
<tr>
<th class="col title"><slot name="title" /></th>
<th class="col detail" v-if="$slots.detail"><slot name="detail" /></th>
<th class="col date" v-if="$slots.date"><slot name="date" /></th>
<th class="col size" v-if="$slots.size"><slot name="size" /></th>
<th class="col buttons" v-if="$slots.buttons"><slot name="buttons" /></th>
</tr>
</template>
<style scoped>
th {
padding: 0.5rem;
white-space: nowrap;
font-size: 0.8em;
text-align: left;
border-bottom: 1px solid #eee;
text-align: right;
}
.title {
text-align: left;
}
</style>

36
src/components/ListItem.vue

@ -0,0 +1,36 @@
<template>
<router-link tag="tr" :to="to || {}">
<td class="col title"><slot name="title" /></td>
<td class="col detail" v-if="$slots.detail"><slot name="detail" /></td>
<td class="col date" v-if="$slots.date"><slot name="date" /></td>
<td class="col size" v-if="$slots.size"><slot name="size" /></td>
<td class="col buttons" v-if="$slots.buttons"><slot name="buttons" /></td>
</router-link>
</template>
<script>
export default {
props: {
to: { type: [Object, String], required: false },
},
};
</script>
<style scoped>
tr:hover {
background: #f4f4f4;
}
td {
padding: 0.5rem 0.75rem;
white-space: nowrap;
border-bottom: 1px solid #eee;
border-bottom-width: 0.5px;
text-align: right;
}
.title {
text-align: left;
}
.detail {
font-size: 0.8em;
}
</style>

46
src/components/LoadableText.vue

@ -0,0 +1,46 @@
<template>
<span :class="{ loading: !text }">{{ text }}<span /><span /><span /></span>
</template>
<script>
export default {
props: {
text: String,
},
};
</script>
<style scoped>
span.loading > span {
display: inline-block;
background: #000;
border-radius: 0.09375em;
width: 0.1875em;
height: 0.1875em;
margin: 0.25em 0.075em;
opacity: 0.5;
}
span.loading > span:nth-child(1) {
animation: fade 1s ease 0s infinite;
}
span.loading > span:nth-child(2) {
animation: fade 1s ease 0.333s infinite;
}
span.loading > span:nth-child(3) {
animation: fade 1s ease 0.666s infinite;
}
@keyframes fade {
0% {
opacity: 1;
}
33.3% {
opacity: 0.25;
}
66.6% {
opacity: 0.25;
}
100% {
opacity: 1;
}
}
</style>

34
src/components/Paginator.vue

@ -0,0 +1,34 @@
<template>
<div>
<!-- <span class="prev" @click="prev">Previous</span> -->
<span class="next" @click="next" v-if="nextLast">Next</span>
</div>
</template>
<script>
export default {
props: {
nextLast: String,
},
methods: {
prev() {
this.$router.back();
},
next() {
this.$router.push(`?last=${this.nextLast}`);
},
},
};
</script>
<style scoped>
div {
display: flex;
justify-content: space-between;
padding-top: 0.75rem;
}
div > span {
padding: 0.75rem;
color: #008;
}
</style>

39
src/components/TagSize.vue

@ -0,0 +1,39 @@
<template>
<LoadableText :text="size" />
</template>
<script>
import filesize from 'filesize';
import { tag, blob } from '@/api';
import LoadableText from '@/components/LoadableText.vue';
export default {
components: {
LoadableText,
},
props: {
repo: String,
tag: String,
},
data() {
return {
size: '',
};
},
async created() {
const r = await tag(this.repo, this.tag);
if (r.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
console.error('V2 manifest lists not supported yet');
return;
}
if (r.schemaVersion === 1) {
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
}
const sizes = await Promise.all(r.layers.map(async layer => (
await blob(this.repo, layer.digest)
).contentLength));
const total = sizes.reduce((a, b) => a + b, 0);
this.size = filesize(total);
},
};
</script>

3
src/components/Toolbar.vue

@ -0,0 +1,3 @@
<template>
<span>No Toolbar</span>
</template>

12
src/main.js

@ -0,0 +1,12 @@
import Vue from 'vue';
import App from '@/App.vue';
import router from '@/router';
import 'normalize.css';
Vue.config.productionTip = false;
new Vue({
router,
render: h => h(App),
}).$mount('#app');

90
src/options.js

@ -0,0 +1,90 @@
const version = process.env.VERSION || '[master]';
const source = process.env.SOURCE_LINK || 'https://github.com/productionwentdown/dri';
const defaultConfig = {
merged: false,
registryHost: process.env.VUE_APP_REGISTRY_HOST,
registryAPI: process.env.VUE_APP_REGISTRY_API,
repositoriesPerPage: process.env.VUE_APP_REPOSITORIES_PER_PAGE,
tagsPerPage: process.env.VUE_APP_TAGS_PER_PAGE,
usePortusExplore: process.env.VUE_APP_USE_PORTUS_EXPLORE,
};
async function config() {
if (defaultConfig.merged) {
return defaultConfig;
}
try {
const response = await fetch('/config.json');
const jsonConfig = await response.json();
defaultConfig.merged = true;
return Object.assign(defaultConfig, jsonConfig);
} catch (_) {
defaultConfig.merged = true;
return defaultConfig;
}
}
async function registryHost() {
const c = await config();
if (c.registryHost) {
return c.registryHost;
}
return window.location.host;
}
async function registryAPI() {
const c = await config();
if (c.registryAPI) {
return c.registryAPI;
}
const host = await registryHost();
// assume API uses the same protocol as the page
// this is because browsers don't allow mixed content
// if a HTTPS API needs to be accessed over HTTP, configure registryAPI
return `${window.location.protocol}//${host}`;
}
async function usePortusExplore() {
const c = await config();
if (c.registryAPI) {
return c.registryAPI;
}
return false;
}
async function repositoriesPerPage() {
const c = await config();
try {
const n = parseInt(c.repositoriesPerPage, 10);
if (n > 0) return n;
} catch (_) {
return 0;
}
return 0;
}
async function tagsPerPage() {
const c = await config();
try {
const n = parseInt(c.tagsPerPage, 10);
if (n > 0) return n;
} catch (_) {
return 0;
}
return 0;
}
export {
version,
source,
registryHost,
registryAPI,
repositoriesPerPage,
tagsPerPage,
usePortusExplore,
};

35
src/router.js

@ -0,0 +1,35 @@
import Vue from 'vue';
import Router from 'vue-router';
import Repos from '@/views/Repos.vue';
import Repo from '@/views/Repo.vue';
import Tag from '@/views/Tag.vue';
import Blob from '@/views/Blob.vue';
Vue.use(Router);
export default new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'repos',
component: Repos,
},
{
path: '/:repo+/blobs/:digest',
name: 'blob',
component: Blob,
},
{
path: '/:repo+/tags/:tag',
name: 'tag',
component: Tag,
},
{
path: '/:repo+',
name: 'repo',
component: Repo,
},
],
});

32
src/views/Blob.vue

@ -0,0 +1,32 @@
<template>
<Layout>
<h1 slot="title">{{ $route.params.repo }}</h1>
<h2>Details</h2>
<List>
<ListItem>
<span slot="title">Full Digest</span>
<span slot="detail">{{ $route.params.digest }}</span>
</ListItem>
<ListItem>
<span slot="title">Size</span>
<BlobSize slot="detail" :repo="$route.params.repo" :blob="$route.params.digest" />
</ListItem>
</List>
</Layout>
</template>
<script>
import Layout from '@/components/Layout.vue';
import List from '@/components/List.vue';
import ListItem from '@/components/ListItem.vue';
import BlobSize from '@/components/BlobSize.vue';
export default {
components: {
Layout,
List,
ListItem,
BlobSize,
},
};
</script>

82
src/views/Repo.vue

@ -0,0 +1,82 @@
<template>
<Layout>
<h1 slot="title">{{ $route.params.repo }}</h1>
<Error slot="error" :message='error' />
<List>
<ListHeader slot="header">
<span slot="title">Tag</span>
<span slot="detail">Pull Commmand</span>
<span slot="size">Size</span>
</ListHeader>
<ListItem
v-for="tag in tags"
:key="tag"
:to="{ name: 'tag', params: { tag, }}">
<span slot="title">
{{ tag }}
</span>
<span slot="detail">
<code>docker pull {{ registryHost }}/{{ $route.params.repo }}:{{ tag }}</code>
</span>
<TagSize slot="size" :repo="$route.params.repo" :tag="tag" />
</ListItem>
</List>
<Paginator :nextLast="nextLast" />
</Layout>
</template>
<script>
import { registryHost } from '@/options';
import { tags } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import Toolbar from '@/components/Toolbar.vue';
import List from '@/components/List.vue';
import ListHeader from '@/components/ListHeader.vue';
import ListItem from '@/components/ListItem.vue';
import Paginator from '@/components/Paginator.vue';
import TagSize from '@/components/TagSize.vue';
export default {
components: {
Layout,
Error,
Toolbar,
List,
ListHeader,
ListItem,
Paginator,
TagSize,
},
data() {
return {
error: '',
registryHost: '',
tags: [],
nextLast: '',
};
},
async created() {
this.registryHost = await registryHost();
await this.fetchTags();
},
methods: {
async fetchTags() {
try {
const r = await tags(this.$route.params.repo, this.$route.query.last);
this.tags = r.tags;
this.nextLast = r.nextLast;
} catch (e) {
console.error(e);
this.error = `Unable to fetch tags (${e.name})`;
}
},
},
watch: {
async $route() {
await this.fetchTags();
},
},
};
</script>

78
src/views/Repos.vue

@ -0,0 +1,78 @@
<template>
<Layout>
<h1 slot="title">Repositories</h1>
<Error slot="error" :message='error' />
<List>
<ListHeader slot="header">
<span slot="title">Repository</span>
<span slot="detail">Pull Commmand</span>
</ListHeader>
<ListItem
v-for="repo in repos"
:key="repo"
:to="{ name: 'repo', params: { repo, }}">
<span slot="title">
{{ repo }}
</span>
<span slot="detail">
<code>docker pull {{ registryHost }}/{{ repo }}</code>
</span>
</ListItem>
</List>
<Paginator :nextLast="nextLast" />
</Layout>
</template>
<script>
import { registryHost } from '@/options';
import { repos } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import Toolbar from '@/components/Toolbar.vue';
import List from '@/components/List.vue';
import ListHeader from '@/components/ListHeader.vue';
import ListItem from '@/components/ListItem.vue';
import Paginator from '@/components/Paginator.vue';
export default {
components: {
Layout,
Error,
Toolbar,
List,
ListHeader,
ListItem,
Paginator,
},
data() {
return {
error: '',
registryHost: '',
repos: [],
nextLast: '',
};
},
async created() {
this.registryHost = await registryHost();
await this.fetchRepos();
},
methods: {
async fetchRepos() {
try {
const r = await repos(this.$route.query.last);
this.repos = r.repositories;
this.nextLast = r.nextLast;
} catch (e) {
console.error(e);
this.error = `Unable to fetch repositories (${e.name})`;
}
},
},
watch: {
async $route() {
await this.fetchRepos();
},
},
};
</script>

105
src/views/Tag.vue

@ -0,0 +1,105 @@
<template>
<Layout>
<h1 slot="title">{{ $route.params.repo }}:{{ $route.params.tag }}</h1>
<Error slot="error" :message='error' />
<h2>Details</h2>
<List>
<ListItem>
<span slot="title">Schema Version</span>
<span slot="detail">{{ tag.schemaVersion }}</span>
</ListItem>
<ListItem>
<span slot="title">Architecture</span>
<span slot="detail">{{ tag.architecture }}</span>
</ListItem>
<ListItem>
<span slot="title">Size</span>
<span slot="detail"><TagSize :repo="$route.params.repo" :tag="$route.params.tag" /></span>
</ListItem>
</List>
<h2>Layers</h2>
<List>
<ListHeader slot="header">
<span slot="title">Digest</span>
<span slot="detail">Summary</span>
<span slot="size">Size</span>
</ListHeader>
<ListItem
v-for="(layer, i) in tag.layers"
:key="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="detail">{{ command(tag, i) }}</span>
<BlobSize slot="size" :repo="$route.params.repo" :blob="layer.digest" />
</ListItem>
</List>
</Layout>
</template>
<script>
import { registryHost } from '@/options';
import { tag } from '@/api';
import Layout from '@/components/Layout.vue';
import Error from '@/components/Error.vue';
import Toolbar from '@/components/Toolbar.vue';
import List from '@/components/List.vue';
import ListHeader from '@/components/ListHeader.vue';
import ListItem from '@/components/ListItem.vue';
import TagSize from '@/components/TagSize.vue';
import BlobSize from '@/components/BlobSize.vue';
export default {
components: {
Layout,
Error,
Toolbar,
List,
ListHeader,
ListItem,
TagSize,
BlobSize,
},
data() {
return {
error: '',
registryHost: '',
tag: {},
layers: [],
};
},
async created() {
this.registryHost = await registryHost();
await this.fetchTag();
},
methods: {
async fetchTag() {
try {
const r = await tag(this.$route.params.repo, this.$route.params.tag);
if (r.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
this.error = 'V2 manifest lists not supported yet';
}
if (r.schemaVersion === 1) {
r.layers = r.fsLayers.map(l => ({ digest: l.blobSum }));
}
this.tag = r;
console.log(r);
} catch (e) {
console.error(e);
this.error = `Unable to fetch tag (${e.name})`;
}
},
identifier(t, n) {
return t.layers[n].digest.split(':')[1].slice(0, 10);
},
command() {
return 'not implemented';
},
},
watch: {
async $route() {
await this.fetchTag();
},
},
};
</script>

3
vue.config.js

@ -0,0 +1,3 @@
module.exports = {
lintOnSave: false,
};

6908
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save