Initial commit

This commit is contained in:
NinjaPug
2025-07-06 12:45:37 -04:00
commit d3e59e3786
34 changed files with 17636 additions and 0 deletions

85
.dockerignore Normal file
View File

@@ -0,0 +1,85 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.angular
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode
.idea
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Git
.git
.gitignore
# Documentation
docs/
*.md
# Docker files (except main ones needed for build)
docker-compose.yml
docker-compose.*.yml
*.dockerfile
# Test files
src/**/*.spec.ts
e2e/
karma.conf.js
*.test.js
# Development files
proxy.conf.json
start-dev.*

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# Dependencies
/node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
# Temporary deletion files
.delete-*

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# Multi-stage build for optimized production image
FROM node:18-alpine as build
# Set working directory
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci --only=production --silent
# Copy source code
COPY . .
# Build the Angular application
RUN npm run build -- --configuration production
# Production stage
FROM nginx:1.25-alpine
# Install curl for health checks
RUN apk add --no-cache curl
# Copy built application
COPY --from=build /app/dist/docker-registry-browser /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Create nginx user and set permissions
RUN addgroup -g 1001 -S nginx && \
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Switch to non-root user
USER nginx
# Add labels for better container management
LABEL maintainer="Your Name <your.email@example.com>"
LABEL description="Docker Registry Browser - A web interface for browsing Docker registries"
LABEL version="1.1.0"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 NinjaPug
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

207
README.md Normal file
View File

@@ -0,0 +1,207 @@
# Docker Registry Browser - Production Deployment
A modern, responsive web interface for browsing Docker registries with support for both Docker v2 and OCI manifest formats.
## Features
- Browse repositories and tags
- View detailed image information (layers, environment, labels, etc.)
- Copy docker pull commands
- Push command generator with examples
- Dark/Light mode toggle
- Fully responsive design
- Support for OCI and Docker v2 manifests
- Multi-platform image support
## Quick Start
### Option 1: Docker Run
```bash
docker run -d \
--name docker-registry-browser \
-p 8080:80 \
--add-host=host.docker.internal:host-gateway \
-e REGISTRY_HOST=localhost:5000 \
-e REGISTRY_PROTOCOL=http \
your-dockerhub-username/docker-registry-browser:latest
```
### Option 2: Docker Compose
```bash
git clone https://github.com/your-username/docker-registry-browser.git
cd docker-registry-browser
docker-compose up -d
```
### Option 3: Build from Source
```bash
git clone https://github.com/your-username/docker-registry-browser.git
cd docker-registry-browser
docker build -t docker-registry-browser .
docker run -d -p 8080:80 --add-host=host.docker.internal:host-gateway docker-registry-browser
```
## Unraid Installation
### Method 1: Community Applications (Recommended)
1. In Unraid, go to **Apps** tab
2. Search for "Docker Registry Browser"
3. Click **Install**
4. Configure the settings and click **Apply**
### Method 2: Manual Template
1. In Unraid, go to **Docker** tab
2. Click **Add Container**
3. Set **Template** to the template URL or upload the XML template
4. Configure the required settings
5. Click **Apply**
### Method 3: Docker Compose (Unraid 6.12+)
1. Install the "Compose Manager" plugin
2. Create a new compose stack with the provided `docker-compose.yml`
3. Deploy the stack
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `REGISTRY_HOST` | `localhost:5000` | Docker registry hostname and port |
| `REGISTRY_PROTOCOL` | `http` | Protocol (http/https) |
| `REGISTRY_USERNAME` | - | Registry username (optional) |
| `REGISTRY_PASSWORD` | - | Registry password (optional) |
### Unraid Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| **WebUI Port** | `8080` | Port for web interface |
| **Registry Host** | `localhost:5000` | Your registry address |
| **Registry Protocol** | `http` | http or https |
| **Registry Username** | - | Optional authentication |
| **Registry Password** | - | Optional authentication |
## Usage
1. Access the web interface at `http://your-server:8080`
2. Browse repositories on the left panel
3. Select a repository to view its tags
4. Click the info button to view detailed image information
5. Use the menu for push commands and settings
6. Toggle dark/light mode using the theme button in the toolbar
## Features Guide
### Dark Mode
- Toggle between light and dark themes using the moon/sun icon in the toolbar
- Theme preference is saved locally and persists between sessions
- All UI components are properly themed for optimal visibility in both modes
### Browsing Images
- Repository list shows all available repositories
- Click a repository to load its tags
- Search repositories using the search field
- View tag details by clicking the info button
### Push Commands
- Access via the menu (three dots) in the toolbar
- Get step-by-step instructions for pushing images
- Copy commands to clipboard
- Includes multi-architecture build instructions
### Image Details
- View comprehensive image information
- See layer details, environment variables, labels
- Check image size, architecture, and creation date
- Inspect exposed ports and volumes
## Troubleshooting
### Registry Connection Issues
**Problem**: Cannot connect to registry
**Solution**:
1. Verify `REGISTRY_HOST` is correct
2. Check if registry is accessible from container
3. For local registries, ensure `--add-host=host.docker.internal:host-gateway` is set
### CORS Issues
**Problem**: API requests blocked by CORS
**Solution**: The nginx configuration includes CORS headers, but ensure your registry allows cross-origin requests
### Authentication Issues
**Problem**: 401 Unauthorized errors
**Solution**: Set `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables
### Manifest Issues
**Problem**: "OCI index found" errors
**Solution**: This should be resolved in the current version which supports OCI manifests
## Development
### Building
```bash
# Install dependencies
npm install
# Development server
npm start
# Build for production
npm run build
# Build Docker image
docker build -t docker-registry-browser .
```
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## Health Check
The container includes a health check endpoint at `/health` that returns:
- `200 OK` with "healthy" response when running properly
- Checks every 30 seconds with 3 retries
## Security
- Runs as non-root user (nginx:nginx)
- Includes security headers
- No sensitive data stored in container
- Registry credentials passed via environment variables
## Support
- **GitHub**: [Issues and Discussions](https://github.com/your-username/docker-registry-browser)
- **Docker Hub**: [Container Images](https://hub.docker.com/r/your-dockerhub-username/docker-registry-browser)
- **Unraid Forums**: [Community Support](https://forums.unraid.net/)
## License
MIT License - see LICENSE file for details.
## Changelog
### v1.0.0
- Initial release
- Support for Docker v2 and OCI manifests
- Responsive design
- Push command generation
- Dark/Light mode
- Multi-platform image support

80
angular.json Normal file
View File

@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"docker-registry-browser": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/docker-registry-browser",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "docker-registry-browser:build:production"
},
"development": {
"buildTarget": "docker-registry-browser:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
},
"cli": {
"analytics": false
}
}

77
build.bat Normal file
View File

@@ -0,0 +1,77 @@
@echo off
REM Docker Registry Browser - Build and Deploy Script for Windows
REM Usage: build.bat [tag] [registry]
setlocal enabledelayedexpansion
REM Configuration
set IMAGE_NAME=docker-registry-browser
set DEFAULT_TAG=latest
set DEFAULT_REGISTRY=
REM Parse arguments
if "%1"=="" (
set TAG=%DEFAULT_TAG%
) else (
set TAG=%1
)
if "%2"=="" (
set REGISTRY=%DEFAULT_REGISTRY%
) else (
set REGISTRY=%2
)
REM Build the full image name
if "%REGISTRY%"=="" (
set FULL_IMAGE_NAME=%IMAGE_NAME%:%TAG%
) else (
set FULL_IMAGE_NAME=%REGISTRY%/%IMAGE_NAME%:%TAG%
)
echo Building Docker Registry Browser...
echo Image: %FULL_IMAGE_NAME%
echo.
REM Build the Docker image
echo Building Docker image...
docker build -t "%FULL_IMAGE_NAME%" .
if %ERRORLEVEL% neq 0 (
echo Build failed!
pause
exit /b 1
)
echo.
echo Build completed successfully!
echo.
echo To run the container:
echo docker run -d --name docker-registry-browser -p 8080:80 --add-host=host.docker.internal:host-gateway %FULL_IMAGE_NAME%
echo.
echo To push to registry (if configured):
if "%REGISTRY%"=="" (
echo Please specify a registry: build.bat %TAG% your-registry.com
) else (
echo docker push %FULL_IMAGE_NAME%
)
echo.
REM Optional: Run the container immediately
set /p REPLY="Do you want to run the container now? (y/N): "
if /i "%REPLY%"=="y" (
echo Starting container...
docker run -d --name docker-registry-browser -p 8080:80 --add-host=host.docker.internal:host-gateway -e REGISTRY_HOST=localhost:5000 -e REGISTRY_PROTOCOL=http "%FULL_IMAGE_NAME%"
if %ERRORLEVEL% equ 0 (
echo.
echo Container started successfully!
echo Access the application at: http://localhost:8080
echo View container logs: docker logs docker-registry-browser
echo Stop container: docker stop docker-registry-browser
) else (
echo Failed to start container!
)
)
pause

64
build.sh Normal file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# Docker Registry Browser - Build and Deploy Script
# Usage: ./build.sh [tag] [registry]
set -e
# Configuration
IMAGE_NAME="docker-registry-browser"
DEFAULT_TAG="latest"
DEFAULT_REGISTRY=""
# Parse arguments
TAG=${1:-$DEFAULT_TAG}
REGISTRY=${2:-$DEFAULT_REGISTRY}
# Build the full image name
if [ -n "$REGISTRY" ]; then
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
else
FULL_IMAGE_NAME="$IMAGE_NAME:$TAG"
fi
echo "Building Docker Registry Browser..."
echo "Image: $FULL_IMAGE_NAME"
echo ""
# Build the Docker image
echo "Building Docker image..."
docker build -t "$FULL_IMAGE_NAME" .
echo ""
echo "Build completed successfully!"
echo ""
echo "To run the container:"
echo "docker run -d --name docker-registry-browser -p 8080:80 --add-host=host.docker.internal:host-gateway $FULL_IMAGE_NAME"
echo ""
echo "To push to registry (if configured):"
if [ -n "$REGISTRY" ]; then
echo "docker push $FULL_IMAGE_NAME"
else
echo "Please specify a registry: ./build.sh $TAG your-registry.com"
fi
echo ""
# Optional: Run the container immediately
read -p "Do you want to run the container now? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Starting container..."
docker run -d \
--name docker-registry-browser \
-p 8080:80 \
--add-host=host.docker.internal:host-gateway \
-e REGISTRY_HOST=localhost:5000 \
-e REGISTRY_PROTOCOL=http \
"$FULL_IMAGE_NAME"
echo ""
echo "Container started successfully!"
echo "Access the application at: http://localhost:8080"
echo "View container logs: docker logs docker-registry-browser"
echo "Stop container: docker stop docker-registry-browser"
fi

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
version: '3.8'
services:
docker-registry-browser:
build: .
container_name: docker-registry-browser
restart: unless-stopped
ports:
- "8080:80"
environment:
# Registry configuration
- REGISTRY_HOST=host.docker.internal:5000
- REGISTRY_PROTOCOL=http
# Optional: Basic auth if your registry requires it
# - REGISTRY_USERNAME=username
# - REGISTRY_PASSWORD=password
extra_hosts:
# For accessing host services (like local registry)
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
# Unraid template labels
- "net.unraid.docker.managed=dockerman"
- "net.unraid.docker.icon=https://raw.githubusercontent.com/docker/docs/main/assets/images/docker-icon.png"
networks:
- registry-network
networks:
registry-network:
driver: bridge

132
nginx.conf Normal file
View File

@@ -0,0 +1,132 @@
# nginx.conf for production deployment
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Basic Settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100m;
# Gzip Settings
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Security Headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security: Hide nginx version
server_tokens off;
# Main application
location / {
try_files $uri $uri/ /index.html;
# Cache control for HTML files (no cache)
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
# Static assets with long-term caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
# Proxy for Docker Registry API (configurable via environment)
location /api/ {
# Remove /api prefix and forward to registry
rewrite ^/api/(.*)$ /$1 break;
# Default to localhost:5000, but can be overridden
proxy_pass http://host.docker.internal:5000;
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers for registry API
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization" always;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
add_header Access-Control-Allow-Headers "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization";
add_header Access-Control-Max-Age 1728000;
add_header Content-Type "text/plain charset=UTF-8";
add_header Content-Length 0;
return 204;
}
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Health check endpoint
location /health {
access_log off;
add_header Content-Type text/plain;
return 200 "healthy\n";
}
# 404 error handling
error_page 404 /index.html;
# 50x error handling
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

14099
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "docker-registry-browser",
"version": "1.1.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"serve": "ng serve --host 0.0.0.0 --port 4200"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/cdk": "^17.0.0",
"@angular/common": "^17.0.0",
"@angular/compiler": "^17.0.0",
"@angular/core": "^17.0.0",
"@angular/forms": "^17.0.0",
"@angular/material": "^17.0.0",
"@angular/platform-browser": "^17.0.0",
"@angular/platform-browser-dynamic": "^17.0.0",
"@angular/router": "^17.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.0.0",
"@angular/cli": "^17.0.0",
"@angular/compiler-cli": "^17.0.0",
"@types/node": "^18.7.0",
"typescript": "~5.2.0"
}
}

16
proxy.conf.json Normal file
View File

@@ -0,0 +1,16 @@
{
"/api/*": {
"target": "http://192.168.1.193:5000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"headers": {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
},
"pathRewrite": {
"^/api": ""
}
}
}

292
src/app/app.component.html Normal file
View File

@@ -0,0 +1,292 @@
<div class="container">
<!-- Header -->
<mat-toolbar color="primary" class="app-toolbar">
<mat-icon>storage</mat-icon>
<span class="toolbar-title">Docker Registry Browser</span>
<span class="spacer"></span>
<!-- Dark Mode Toggle -->
<button mat-icon-button (click)="toggleTheme()" [matTooltip]="isDarkMode ? 'Light mode' : 'Dark mode'">
<mat-icon>{{ isDarkMode ? 'light_mode' : 'dark_mode' }}</mat-icon>
</button>
<!-- Menu Button -->
<button mat-icon-button [matMenuTriggerFor]="menu" matTooltip="Actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="openPushCommandsDialog()">
<mat-icon>cloud_upload</mat-icon>
<span>Push Image Commands</span>
</button>
</mat-menu>
<button mat-raised-button color="accent" (click)="loadRepositories()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
Refresh
</button>
</mat-toolbar>
<div class="content">
<!-- Registry Configuration -->
<mat-card class="config-card">
<mat-card-header>
<mat-card-title>
<mat-icon>settings</mat-icon>
Registry Configuration
</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>Connected to: <code>{{ registryHost }}</code></p>
<p><small>Using development proxy to bypass CORS restrictions</small></p>
</mat-card-content>
</mat-card>
<!-- Copy Success Message -->
<div *ngIf="copyMessage" class="copy-message">
<mat-icon>check_circle</mat-icon>
{{ copyMessage }}
</div>
<!-- Error Display -->
<mat-card *ngIf="error" class="error-card">
<mat-card-content>
<div class="error-content">
<mat-icon color="warn">error</mat-icon>
<span>{{ error }}</span>
</div>
</mat-card-content>
</mat-card>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Repositories List -->
<mat-card class="repositories-card">
<mat-card-header>
<mat-card-title>
<mat-icon>folder</mat-icon>
Repositories ({{ filteredRepositories.length }})
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Search -->
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search repositories</mat-label>
<input matInput [(ngModel)]="searchTerm" placeholder="Filter repositories...">
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<!-- Loading -->
<div *ngIf="loading" class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<p>Loading repositories...</p>
</div>
<!-- Repositories List -->
<mat-nav-list *ngIf="!loading">
<mat-list-item *ngFor="let repo of filteredRepositories"
(click)="loadTags(repo)"
[class.selected]="selectedRepo?.name === repo.name">
<mat-icon matListItemIcon>folder</mat-icon>
<div matListItemTitle>{{ repo.name }}</div>
<mat-icon matListItemMeta>chevron_right</mat-icon>
</mat-list-item>
<mat-list-item *ngIf="filteredRepositories.length === 0 && !loading">
<div matListItemTitle class="no-data">No repositories found</div>
</mat-list-item>
</mat-nav-list>
</mat-card-content>
</mat-card>
<!-- Tags List -->
<mat-card class="tags-card">
<mat-card-header>
<mat-card-title>
<mat-icon>label</mat-icon>
Tags
<span *ngIf="selectedRepo" class="tag-count">({{ selectedRepo.name }}) - {{ tags.length }} tag{{ tags.length !== 1 ? 's' : '' }}</span>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div *ngIf="!selectedRepo" class="no-selection">
<mat-icon>info</mat-icon>
<p>Select a repository to view tags</p>
<button mat-raised-button color="primary" (click)="openPushCommandsDialog()">
<mat-icon>cloud_upload</mat-icon>
View Push Commands
</button>
</div>
<!-- Loading -->
<div *ngIf="loading && selectedRepo" class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<p>Loading tags...</p>
</div>
<!-- Tags List -->
<div *ngIf="selectedRepo && !loading" class="tags-container">
<div class="tags-list-wrapper">
<mat-nav-list>
<mat-list-item *ngFor="let tag of tags" class="tag-item">
<mat-icon matListItemIcon>label</mat-icon>
<div matListItemTitle class="tag-name">{{ tag.name }}</div>
<div matListItemMeta class="tag-actions">
<button mat-icon-button
color="accent"
matTooltip="View image details"
(click)="loadImageDetails(tag)">
<mat-icon>info</mat-icon>
</button>
<button mat-icon-button
color="primary"
matTooltip="Copy docker pull command"
(click)="copyToClipboard(getDockerPullCommand(selectedRepo!.name, tag.name))">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</mat-list-item>
<mat-list-item *ngIf="tags.length === 0">
<div matListItemTitle class="no-data">No tags found</div>
</mat-list-item>
</mat-nav-list>
</div>
<!-- Image Details Panel -->
<div *ngIf="showingDetails && selectedTag" class="image-details-panel">
<div class="details-header">
<h3>
<mat-icon>info</mat-icon>
Image Details: {{ selectedTag.name }}
</h3>
<button mat-icon-button (click)="closeDetails()" matTooltip="Close details">
<mat-icon>close</mat-icon>
</button>
</div>
<div *ngIf="loadingDetails" class="loading-container">
<mat-spinner diameter="30"></mat-spinner>
<p>Loading image details...</p>
</div>
<div *ngIf="!loadingDetails && selectedTag.details" class="details-content">
<!-- Basic Info -->
<div class="details-section">
<h4>Basic Information</h4>
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Size:</span>
<span class="detail-value">{{ formatBytes(selectedTag.details.size) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Architecture:</span>
<span class="detail-value">{{ selectedTag.details.architecture }}</span>
</div>
<div class="detail-item">
<span class="detail-label">OS:</span>
<span class="detail-value">{{ selectedTag.details.os }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDate(selectedTag.details.created) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Layers:</span>
<span class="detail-value">{{ selectedTag.details.layers.length }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Digest:</span>
<span class="detail-value digest">{{ selectedTag.details.digest.substring(7, 19) }}...</span>
</div>
</div>
</div>
<!-- Configuration -->
<div class="details-section" *ngIf="selectedTag.details.config">
<h4>Configuration</h4>
<div class="config-grid">
<div *ngIf="selectedTag.details.config.workingDir" class="detail-item">
<span class="detail-label">Working Directory:</span>
<span class="detail-value">{{ selectedTag.details.config.workingDir }}</span>
</div>
<div *ngIf="selectedTag.details.config.user" class="detail-item">
<span class="detail-label">User:</span>
<span class="detail-value">{{ selectedTag.details.config.user }}</span>
</div>
</div>
<!-- Environment Variables -->
<div *ngIf="selectedTag.details.config.env && selectedTag.details.config.env.length > 0" class="env-section">
<h5>Environment Variables</h5>
<div class="env-list">
<div *ngFor="let env of selectedTag.details.config.env" class="env-item">
<code>{{ env }}</code>
</div>
</div>
</div>
<!-- Exposed Ports -->
<div *ngIf="hasObjectKeys(selectedTag.details.config.exposedPorts)" class="ports-section">
<h5>Exposed Ports</h5>
<div class="ports-list">
<span *ngFor="let port of getObjectKeys(selectedTag.details.config.exposedPorts)" class="port-chip">
{{ port }}
</span>
</div>
</div>
<!-- Labels -->
<div *ngIf="hasObjectKeys(selectedTag.details.config.labels)" class="labels-section">
<h5>Labels</h5>
<div class="labels-list">
<div *ngFor="let label of getObjectKeys(selectedTag.details.config.labels)" class="label-item">
<span class="label-key">{{ label }}:</span>
<span class="label-value">{{ getLabelValue(selectedTag.details.config.labels, label) }}</span>
</div>
</div>
</div>
</div>
<!-- Layers -->
<div class="details-section">
<h4>Layers ({{ selectedTag.details.layers.length }})</h4>
<div class="layers-list">
<div *ngFor="let layer of selectedTag.details.layers; let i = index" class="layer-item">
<div class="layer-info">
<span class="layer-number">{{ i + 1 }}</span>
<span class="layer-size">{{ formatBytes(layer.size) }}</span>
<span class="layer-digest">{{ layer.digest.substring(7, 19) }}...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Docker Pull Commands -->
<mat-expansion-panel *ngIf="tags.length > 0" class="pull-commands">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>code</mat-icon>
Docker Pull Commands
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-content">
<div *ngFor="let tag of tags" class="command-item">
<div class="command-text">
<code>{{ getDockerPullCommand(selectedRepo!.name, tag.name) }}</code>
</div>
<button mat-icon-button
color="primary"
(click)="copyToClipboard(getDockerPullCommand(selectedRepo!.name, tag.name))"
matTooltip="Copy to clipboard">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</mat-expansion-panel>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

1057
src/app/app.component.scss Normal file

File diff suppressed because it is too large Load Diff

157
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,157 @@
import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DockerRegistryService } from './services/docker-registry.service';
import { Repository, Tag, ImageDetails } from './models/registry.model';
import { PushCommandsDialogComponent } from './components/push-commands-dialog.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
registryUrl = '/api'; // Using proxy for CORS
registryHost = '192.168.1.193:5000'; // Actual registry host for commands
repositories: Repository[] = [];
selectedRepo: Repository | null = null;
tags: Tag[] = [];
loading = false;
loadingDetails = false;
error = '';
searchTerm = '';
copyMessage = '';
selectedTag: Tag | null = null;
showingDetails = false;
isDarkMode = false;
constructor(
private registryService: DockerRegistryService,
private dialog: MatDialog
) {}
ngOnInit() {
// Check for saved theme preference
const savedTheme = localStorage.getItem('theme');
this.isDarkMode = savedTheme === 'dark';
this.applyTheme();
this.loadRepositories();
}
toggleTheme() {
this.isDarkMode = !this.isDarkMode;
localStorage.setItem('theme', this.isDarkMode ? 'dark' : 'light');
this.applyTheme();
}
private applyTheme() {
if (this.isDarkMode) {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}
openPushCommandsDialog() {
this.dialog.open(PushCommandsDialogComponent, {
width: '800px',
maxWidth: '95vw',
maxHeight: '90vh',
data: {
registryHost: this.registryHost,
selectedRepo: this.selectedRepo?.name
}
});
}
async loadRepositories() {
this.loading = true;
this.error = '';
try {
this.repositories = await this.registryService.getRepositories(this.registryUrl);
} catch (err: any) {
this.error = `Failed to fetch repositories: ${err.message}`;
this.repositories = [];
}
this.loading = false;
}
async loadTags(repo: Repository) {
this.loading = true;
this.error = '';
this.selectedRepo = repo;
this.selectedTag = null;
this.showingDetails = false;
try {
this.tags = await this.registryService.getTags(this.registryUrl, repo.name);
} catch (err: any) {
this.error = `Failed to fetch tags: ${err.message}`;
this.tags = [];
}
this.loading = false;
}
async loadImageDetails(tag: Tag) {
if (!this.selectedRepo) return;
this.loadingDetails = true;
this.selectedTag = tag;
this.showingDetails = true;
try {
if (!tag.details) {
tag.details = await this.registryService.getImageDetails(
this.registryUrl,
this.selectedRepo.name,
tag.name
);
}
} catch (err: any) {
this.error = `Failed to fetch image details: ${err.message}`;
}
this.loadingDetails = false;
}
closeDetails() {
this.selectedTag = null;
this.showingDetails = false;
}
getDockerPullCommand(repoName: string, tagName: string): string {
return `docker pull ${this.registryHost}/${repoName}:${tagName}`;
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
this.copyMessage = 'Copied to clipboard!';
setTimeout(() => {
this.copyMessage = '';
}, 2000);
}
formatBytes = (bytes: number): string => {
return this.registryService.formatBytes(bytes);
}
formatDate = (dateString: string): string => {
return this.registryService.formatDate(dateString);
}
getObjectKeys(obj: any): string[] {
return obj ? Object.keys(obj) : [];
}
hasObjectKeys(obj: any): boolean {
return obj && Object.keys(obj).length > 0;
}
getLabelValue(labels: { [key: string]: string } | undefined, key: string): string {
return labels?.[key] || 'N/A';
}
get filteredRepositories(): Repository[] {
return this.repositories.filter(repo =>
repo.name.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
}

52
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
// Angular Material imports
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatDividerModule } from '@angular/material/divider';
import { AppComponent } from './app.component';
import { PushCommandsDialogComponent } from './components/push-commands-dialog.component';
@NgModule({
declarations: [
AppComponent,
PushCommandsDialogComponent
],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
FormsModule,
MatToolbarModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatListModule,
MatProgressSpinnerModule,
MatExpansionModule,
MatTooltipModule,
MatDialogModule,
MatMenuModule,
MatDividerModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -0,0 +1,188 @@
<div class="push-dialog">
<h2 mat-dialog-title>
<mat-icon>cloud_upload</mat-icon>
Push Image to Registry
</h2>
<mat-dialog-content class="dialog-content">
<div class="intro-text">
<p>Use these commands to build, tag, and push Docker images to the registry:</p>
<p><strong>Registry:</strong> <code>{{ data.registryHost }}</code></p>
</div>
<!-- Build and Push Section -->
<mat-expansion-panel class="command-section" expanded>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>build</mat-icon>
Build and Push New Image
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-list">
<div class="command-step">
<h4>1. Build your Docker image</h4>
<div class="command-item">
<code>docker build -t my-app:latest .</code>
<button mat-icon-button (click)="copyToClipboard('docker build -t my-app:latest .')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="command-step">
<h4>2. Tag the image for the registry</h4>
<div class="command-item">
<code>docker tag my-app:latest {{ data.registryHost }}/my-app:latest</code>
<button mat-icon-button (click)="copyToClipboard('docker tag my-app:latest ' + data.registryHost + '/my-app:latest')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="command-step">
<h4>3. Push to registry</h4>
<div class="command-item">
<code>docker push {{ data.registryHost }}/my-app:latest</code>
<button mat-icon-button (click)="copyToClipboard('docker push ' + data.registryHost + '/my-app:latest')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Push Existing Image Section -->
<mat-expansion-panel class="command-section">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>cloud_upload</mat-icon>
Push Existing Image
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-list">
<div class="command-step">
<h4>Tag existing image</h4>
<div class="command-item">
<code>docker tag &lt;existing-image&gt; {{ data.registryHost }}/&lt;repository&gt;:&lt;tag&gt;</code>
<button mat-icon-button (click)="copyToClipboard('docker tag <existing-image> ' + data.registryHost + '/<repository>:<tag>')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="command-step">
<h4>Push to registry</h4>
<div class="command-item">
<code>docker push {{ data.registryHost }}/&lt;repository&gt;:&lt;tag&gt;</code>
<button mat-icon-button (click)="copyToClipboard('docker push ' + data.registryHost + '/<repository>:<tag>')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Push to Specific Repository Section -->
<mat-expansion-panel class="command-section" *ngIf="data.selectedRepo">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>folder</mat-icon>
Push to "{{ data.selectedRepo }}"
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-list">
<div class="command-step">
<h4>Tag for this repository</h4>
<div class="command-item">
<code>docker tag &lt;your-image&gt; {{ data.registryHost }}/{{ data.selectedRepo }}:&lt;tag&gt;</code>
<button mat-icon-button (click)="copyToClipboard('docker tag <your-image> ' + data.registryHost + '/' + data.selectedRepo + ':<tag>')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="command-step">
<h4>Push to this repository</h4>
<div class="command-item">
<code>docker push {{ data.registryHost }}/{{ data.selectedRepo }}:&lt;tag&gt;</code>
<button mat-icon-button (click)="copyToClipboard('docker push ' + data.registryHost + '/' + data.selectedRepo + ':<tag>')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Multi-arch Build Section -->
<mat-expansion-panel class="command-section">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>architecture</mat-icon>
Multi-Architecture Build
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-list">
<div class="command-step">
<h4>Create and use buildx builder</h4>
<div class="command-item">
<code>docker buildx create --use</code>
<button mat-icon-button (click)="copyToClipboard('docker buildx create --use')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="command-step">
<h4>Build and push multi-arch image</h4>
<div class="command-item">
<code>docker buildx build --platform linux/amd64,linux/arm64 -t {{ data.registryHost }}/my-app:latest --push .</code>
<button mat-icon-button (click)="copyToClipboard('docker buildx build --platform linux/amd64,linux/arm64 -t ' + data.registryHost + '/my-app:latest --push .')" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Registry Configuration Section -->
<mat-expansion-panel class="command-section">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>settings</mat-icon>
Registry Configuration
</mat-panel-title>
</mat-expansion-panel-header>
<div class="commands-list">
<div class="info-section">
<h4>Allow insecure registry (if needed)</h4>
<p>Add this to your Docker daemon configuration (<code>/etc/docker/daemon.json</code> on Linux/Mac or Docker Desktop settings):</p>
<div class="command-item">
<code>{{ dockerDaemonConfig }}</code>
<button mat-icon-button (click)="copyDaemonConfig()" matTooltip="Copy">
<mat-icon>content_copy</mat-icon>
</button>
</div>
<p><small>Restart Docker daemon after making this change.</small></p>
</div>
</div>
</mat-expansion-panel>
<div class="copy-success" *ngIf="copyMessage">
<mat-icon>check_circle</mat-icon>
{{ copyMessage }}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="dialogRef.close()">Close</button>
<button mat-raised-button color="primary" (click)="openDockerDocs()">
<mat-icon>help</mat-icon>
Docker Docs
</button>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,138 @@
.push-dialog {
width: 100%;
max-width: 800px;
}
.dialog-content {
max-height: 70vh;
overflow-y: auto;
}
.intro-text {
margin-bottom: 20px;
padding: 16px;
background-color: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #3f51b5;
}
.dark-theme .intro-text {
background-color: #333;
color: #fff;
}
.command-section {
margin-bottom: 16px;
}
.commands-list {
padding: 16px 0;
}
.command-step {
margin-bottom: 24px;
}
.command-step h4 {
margin: 0 0 8px 0;
color: #3f51b5;
font-size: 0.95em;
font-weight: 600;
}
.dark-theme .command-step h4 {
color: #64b5f6;
}
.command-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.command-item code {
flex: 1;
background-color: #f8f9fa;
padding: 12px 16px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
border: 1px solid #e0e0e0;
white-space: pre-wrap;
word-break: break-all;
}
.dark-theme .command-item code {
background-color: #333;
color: #fff;
border-color: #555;
}
.info-section {
padding: 16px 0;
}
.info-section h4 {
margin: 0 0 12px 0;
color: #3f51b5;
font-weight: 600;
}
.dark-theme .info-section h4 {
color: #64b5f6;
}
.info-section p {
margin: 8px 0;
color: #666;
font-size: 0.9em;
}
.dark-theme .info-section p {
color: #ccc;
}
.copy-success {
display: flex;
align-items: center;
gap: 8px;
background-color: #e8f5e8;
color: #2e7d32;
padding: 12px 16px;
border-radius: 6px;
margin-top: 16px;
border: 1px solid #c8e6c9;
font-weight: 500;
}
.dark-theme .copy-success {
background-color: #2d5016;
color: #90ee90;
border-color: #4caf50;
}
mat-dialog-actions {
padding: 16px 24px;
}
@media (max-width: 768px) {
.push-dialog {
max-width: 95vw;
}
.command-item {
flex-direction: column;
align-items: stretch;
}
.command-item code {
margin-bottom: 8px;
font-size: 12px;
padding: 10px 12px;
}
.command-item button {
align-self: flex-end;
}
}

View File

@@ -0,0 +1,43 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
export interface PushDialogData {
registryHost: string;
selectedRepo?: string;
}
@Component({
selector: 'app-push-commands-dialog',
templateUrl: './push-commands-dialog.component.html',
styleUrls: ['./push-commands-dialog.component.scss']
})
export class PushCommandsDialogComponent {
copyMessage = '';
constructor(
public dialogRef: MatDialogRef<PushCommandsDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: PushDialogData
) {}
get dockerDaemonConfig(): string {
return `{
"insecure-registries": ["${this.data.registryHost}"]
}`;
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
this.copyMessage = 'Copied to clipboard!';
setTimeout(() => {
this.copyMessage = '';
}, 2000);
}
copyDaemonConfig() {
this.copyToClipboard(this.dockerDaemonConfig);
}
openDockerDocs() {
window.open('https://docs.docker.com/engine/reference/commandline/push/', '_blank');
}
}

View File

@@ -0,0 +1,72 @@
export interface Repository {
name: string;
}
export interface Tag {
name: string;
details?: ImageDetails;
}
export interface RegistryResponse {
repositories: string[];
}
export interface TagsResponse {
name: string;
tags: string[];
}
export interface ImageDetails {
digest: string;
mediaType: string;
size: number;
created: string;
architecture: string;
os: string;
layers: LayerInfo[];
config: ImageConfig;
}
export interface LayerInfo {
digest: string;
size: number;
mediaType: string;
}
export interface ImageConfig {
labels?: { [key: string]: string };
env?: string[];
cmd?: string[];
entrypoint?: string[];
workingDir?: string;
user?: string;
exposedPorts?: { [key: string]: any };
volumes?: { [key: string]: any };
}
export interface ManifestResponse {
schemaVersion: number;
mediaType: string;
config: {
digest: string;
mediaType: string;
size: number;
};
layers: {
digest: string;
mediaType: string;
size: number;
}[];
}
export interface ConfigResponse {
created: string;
architecture: string;
os: string;
config: ImageConfig;
history: {
created: string;
created_by: string;
empty_layer?: boolean;
}[];
}

View File

@@ -0,0 +1,199 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { Repository, Tag, RegistryResponse, TagsResponse, ImageDetails, ManifestResponse, ConfigResponse } from '../models/registry.model';
@Injectable({
providedIn: 'root'
})
export class DockerRegistryService {
constructor(private http: HttpClient) {}
async getRepositories(registryUrl: string): Promise<Repository[]> {
try {
const response = await firstValueFrom(
this.http.get<RegistryResponse>(`${registryUrl}/v2/_catalog`)
);
return (response?.repositories || []).map(name => ({ name }));
} catch (error) {
throw new Error(`Failed to connect to registry: ${error}`);
}
}
async getTags(registryUrl: string, repositoryName: string): Promise<Tag[]> {
try {
const response = await firstValueFrom(
this.http.get<TagsResponse>(`${registryUrl}/v2/${repositoryName}/tags/list`)
);
return (response?.tags || []).map(name => ({ name }));
} catch (error) {
throw new Error(`Failed to fetch tags for ${repositoryName}: ${error}`);
}
}
async getImageDetails(registryUrl: string, repositoryName: string, tag: string): Promise<ImageDetails> {
try {
// Enhanced Accept header to support both Docker v2 and OCI manifest formats
const manifestHeaders = new HttpHeaders({
'Accept': [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.oci.image.index.v1+json'
].join(', ')
});
const manifest = await firstValueFrom(
this.http.get<any>(
`${registryUrl}/v2/${repositoryName}/manifests/${tag}`,
{ headers: manifestHeaders }
)
);
console.log('Manifest response:', manifest);
// Handle different manifest types
if (this.isManifestList(manifest)) {
// Handle manifest lists (multi-platform images)
return await this.handleManifestList(registryUrl, repositoryName, tag, manifest);
} else if (this.isImageManifest(manifest)) {
// Handle single platform manifests
return await this.handleImageManifest(registryUrl, repositoryName, manifest);
} else {
throw new Error(`Unsupported manifest type: ${manifest.mediaType}`);
}
} catch (error) {
console.error('Error fetching image details:', error);
throw new Error(`Failed to fetch image details for ${repositoryName}:${tag}: ${error}`);
}
}
private isManifestList(manifest: any): boolean {
return manifest.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json' ||
manifest.mediaType === 'application/vnd.oci.image.index.v1+json';
}
private isImageManifest(manifest: any): boolean {
return manifest.mediaType === 'application/vnd.docker.distribution.manifest.v2+json' ||
manifest.mediaType === 'application/vnd.oci.image.manifest.v1+json';
}
private async handleManifestList(registryUrl: string, repositoryName: string, tag: string, manifestList: any): Promise<ImageDetails> {
// For manifest lists, we'll use the first manifest (usually linux/amd64)
// You could enhance this to let users choose the platform
const firstManifest = manifestList.manifests?.[0];
if (!firstManifest) {
throw new Error('No manifests found in manifest list');
}
console.log('Using manifest from list:', firstManifest);
// Fetch the actual manifest using its digest
const manifestHeaders = new HttpHeaders({
'Accept': [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json'
].join(', ')
});
const actualManifest = await firstValueFrom(
this.http.get<any>(
`${registryUrl}/v2/${repositoryName}/manifests/${firstManifest.digest}`,
{ headers: manifestHeaders }
)
);
return await this.handleImageManifest(registryUrl, repositoryName, actualManifest, firstManifest);
}
private async handleImageManifest(registryUrl: string, repositoryName: string, manifest: any, platformInfo?: any): Promise<ImageDetails> {
try {
// Get the config blob
const configDigest = manifest.config?.digest;
if (!configDigest) {
throw new Error('No config digest found in manifest');
}
const config = await firstValueFrom(
this.http.get<ConfigResponse>(
`${registryUrl}/v2/${repositoryName}/blobs/${configDigest}`
)
);
console.log('Config response:', config);
// Calculate total size
const layers = manifest.layers || [];
const totalSize = layers.reduce((sum: number, layer: any) => sum + (layer.size || 0), 0) + (manifest.config?.size || 0);
return {
digest: configDigest,
mediaType: manifest.mediaType,
size: totalSize,
created: config.created || new Date().toISOString(),
architecture: config.architecture || platformInfo?.platform?.architecture || 'unknown',
os: config.os || platformInfo?.platform?.os || 'unknown',
layers: layers.map((layer: any) => ({
digest: layer.digest,
size: layer.size || 0,
mediaType: layer.mediaType || 'application/vnd.docker.image.rootfs.diff.tar.gzip'
})),
config: config.config || {}
};
} catch (error) {
console.error('Error handling image manifest:', error);
throw error;
}
}
formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatDate(dateString: string): string {
try {
return new Date(dateString).toLocaleString();
} catch (error) {
return 'Invalid Date';
}
}
// Helper method to get available platforms for a manifest list
async getAvailablePlatforms(registryUrl: string, repositoryName: string, tag: string): Promise<any[]> {
try {
const manifestHeaders = new HttpHeaders({
'Accept': [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.index.v1+json'
].join(', ')
});
const manifest = await firstValueFrom(
this.http.get<any>(
`${registryUrl}/v2/${repositoryName}/manifests/${tag}`,
{ headers: manifestHeaders }
)
);
if (this.isManifestList(manifest)) {
return manifest.manifests?.map((m: any) => ({
digest: m.digest,
platform: m.platform,
size: m.size
})) || [];
}
return [];
} catch (error) {
console.error('Error getting platforms:', error);
return [];
}
}
}

View File

@@ -0,0 +1,3 @@
export const environment = {
production: false
};

1
src/favicon.ico Normal file
View File

@@ -0,0 +1 @@
<!-- Placeholder favicon.ico - Replace with actual favicon -->

17
src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Docker Registry Browser</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));

1
src/polyfills.ts Normal file
View File

@@ -0,0 +1 @@
import 'zone.js';

332
src/styles.scss Normal file
View File

@@ -0,0 +1,332 @@
@use '@angular/material' as mat;
// Include the common styles for Angular Material
@include mat.core();
// Define a light theme
$light-primary: mat.define-palette(mat.$indigo-palette);
$light-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
$light-warn: mat.define-palette(mat.$red-palette);
$light-theme: mat.define-light-theme((
color: (
primary: $light-primary,
accent: $light-accent,
warn: $light-warn,
)
));
// Define a dark theme
$dark-primary: mat.define-palette(mat.$blue-grey-palette);
$dark-accent: mat.define-palette(mat.$amber-palette, A200, A100, A400);
$dark-warn: mat.define-palette(mat.$deep-orange-palette);
$dark-theme: mat.define-dark-theme((
color: (
primary: $dark-primary,
accent: $dark-accent,
warn: $dark-warn,
)
));
// Apply the light theme by default
@include mat.all-component-themes($light-theme);
// Apply the dark theme when the .dark-theme class is present
.dark-theme {
@include mat.all-component-colors($dark-theme);
}
// Ensure Material Icons load properly
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
.material-icons {
font-family: 'Material Icons' !important;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
mat-icon {
font-family: 'Material Icons' !important;
font-feature-settings: 'liga';
}
.mat-icon {
font-family: 'Material Icons' !important;
}
html, body {
height: 100%;
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
// Custom dark theme enhancements
.dark-theme {
background-color: #121212;
color: #ffffff;
.content {
background-color: #121212;
}
.copy-message {
background-color: #2d5016;
color: #90ee90;
border: 1px solid #4caf50;
}
.error-card {
background-color: #4a1a1a;
}
.error-content {
color: #ffaaaa;
}
.no-data,
.no-selection {
color: #cccccc;
}
.loading-container p {
color: #cccccc;
}
.tag-count {
color: #cccccc;
}
.tag-name {
color: #ffffff;
}
.image-details-panel {
background-color: #1e1e1e;
border: 1px solid #444444;
.details-header {
background-color: #424242 !important;
color: white !important;
h3 {
color: white !important;
}
button {
color: white !important;
}
}
.details-content {
background-color: #1e1e1e;
.details-section {
h4 {
color: #64b5f6 !important;
}
h5 {
color: #cccccc !important;
}
}
.details-grid {
.detail-item {
.detail-label {
color: #999999 !important;
}
.detail-value {
color: #ffffff !important;
&.digest {
background-color: #2a2a2a !important;
color: #64b5f6 !important;
border: 1px solid #444444;
font-family: 'Courier New', monospace;
}
}
}
}
.config-grid {
.detail-item {
.detail-label {
color: #999999 !important;
}
.detail-value {
color: #ffffff !important;
}
}
}
.env-item code {
background-color: #333333;
border: 1px solid #555555;
color: #ffffff;
}
.port-chip {
background-color: #1976d2;
color: #ffffff;
border: 1px solid #2196f3;
}
.label-item {
border-bottom: 1px solid #444444;
.label-key {
color: #cccccc;
}
.label-value {
color: #ffffff;
}
}
.layer-item {
background-color: #333333;
border: 1px solid #555555;
.layer-info {
.layer-number {
background-color: #616161 !important;
color: white !important;
}
.layer-size {
background-color: #444444;
color: #cccccc;
}
.layer-digest {
color: #999999;
}
}
}
.layers-list {
.layer-item {
background-color: #333333;
border: 1px solid #555555;
color: #ffffff;
}
}
}
}
.pull-commands {
background-color: #1e1e1e;
border: 1px solid #444444;
.mat-expansion-panel-header {
background-color: #333333 !important;
&:hover {
background-color: #424242 !important;
}
}
.commands-content {
background-color: #1e1e1e;
.command-item {
border-bottom: 1px solid #444444;
}
.command-text code {
background-color: #333333;
border: 1px solid #555555;
color: #ffffff;
}
}
}
// Material card enhancements
mat-card {
background-color: #1e1e1e;
color: #ffffff;
&.config-card {
background-color: #1e1e1e;
code {
background-color: #333333;
color: #64b5f6;
padding: 2px 6px;
border-radius: 3px;
}
}
}
// List items in dark mode
mat-list-item {
&:hover {
background-color: #333333 !important;
}
&.selected {
background-color: #424242 !important;
}
}
// Search field in dark mode
.mat-mdc-form-field {
.mat-mdc-text-field-wrapper {
background-color: #333333 !important;
}
.mat-mdc-form-field-focus-overlay {
background-color: rgba(255, 255, 255, 0.05);
}
}
// Dialogs in dark mode
.mat-mdc-dialog-container {
background-color: #1e1e1e !important;
color: #ffffff;
}
// Expansion panels
mat-expansion-panel {
background-color: #1e1e1e;
.mat-expansion-panel-header {
background-color: #333333;
&:hover {
background-color: #424242 !important;
}
}
}
// Scrollbars in dark mode
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: #555555;
border-radius: 6px;
&:hover {
background: #666666;
}
}
}

29
start-dev.bat Normal file
View File

@@ -0,0 +1,29 @@
@echo off
echo 🐳 Docker Registry Browser - Quick Start
echo ========================================
REM Check if npm is installed
npm --version >nul 2>&1
if errorlevel 1 (
echo ❌ npm is not installed. Please install Node.js and npm first.
pause
exit /b 1
)
REM Check if Angular CLI is installed globally
ng version >nul 2>&1
if errorlevel 1 (
echo 📦 Installing Angular CLI globally...
npm install -g @angular/cli
)
REM Install dependencies
echo 📦 Installing dependencies...
npm install
REM Start the development server
echo 🚀 Starting development server...
echo The application will be available at http://localhost:4200
npm start
pause

25
start-dev.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
echo "🐳 Docker Registry Browser - Quick Start"
echo "========================================"
# Check if npm is installed
if ! command -v npm &> /dev/null; then
echo "❌ npm is not installed. Please install Node.js and npm first."
exit 1
fi
# Check if Angular CLI is installed globally
if ! command -v ng &> /dev/null; then
echo "📦 Installing Angular CLI globally..."
npm install -g @angular/cli
fi
# Install dependencies
echo "📦 Installing dependencies..."
npm install
# Start the development server
echo "🚀 Starting development server..."
echo "The application will be available at http://localhost:4200"
npm start

14
tsconfig.app.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

30
unraid-template.xml Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<Container version="2">
<n>docker-registry-browser</n>
<Repository>your-dockerhub-username/docker-registry-browser:latest</Repository>
<Registry>https://hub.docker.com/</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://github.com/your-username/docker-registry-browser</Support>
<Project>https://github.com/your-username/docker-registry-browser</Project>
<Overview>Docker Registry Browser - A modern web interface for browsing and managing Docker registries. Features include repository browsing, tag listing, image details inspection, and push command generation.</Overview>
<Category>Tools:</Category>
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
<TemplateURL>https://raw.githubusercontent.com/your-username/docker-registry-browser/main/unraid-template.xml</TemplateURL>
<Icon>https://raw.githubusercontent.com/docker/docs/main/assets/images/docker-icon.png</Icon>
<ExtraParams>--add-host=host.docker.internal:host-gateway</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires/>
<Config Name="WebUI Port" Target="80" Default="8080" Mode="tcp" Description="Port for accessing the web interface" Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="Registry Host" Target="REGISTRY_HOST" Default="localhost:5000" Mode="" Description="Docker registry host and port (e.g., localhost:5000, registry.example.com:5000)" Type="Variable" Display="always" Required="true" Mask="false">localhost:5000</Config>
<Config Name="Registry Protocol" Target="REGISTRY_PROTOCOL" Default="http" Mode="" Description="Protocol to use for registry connection (http or https)" Type="Variable" Display="always" Required="true" Mask="false">http</Config>
<Config Name="Registry Username" Target="REGISTRY_USERNAME" Default="" Mode="" Description="Username for registry authentication (leave empty for no auth)" Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Registry Password" Target="REGISTRY_PASSWORD" Default="" Mode="" Description="Password for registry authentication (leave empty for no auth)" Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="App Data" Target="/app/data" Default="/mnt/user/appdata/docker-registry-browser" Mode="rw" Description="Application data directory" Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/docker-registry-browser</Config>
</Container>