Initial commit
This commit is contained in:
85
.dockerignore
Normal file
85
.dockerignore
Normal 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
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
53
Dockerfile
Normal 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
21
LICENSE
Normal 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
207
README.md
Normal 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
80
angular.json
Normal 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
77
build.bat
Normal 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
64
build.sh
Normal 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
35
docker-compose.yml
Normal 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
132
nginx.conf
Normal 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
14099
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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
16
proxy.conf.json
Normal 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
292
src/app/app.component.html
Normal 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
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
157
src/app/app.component.ts
Normal 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
52
src/app/app.module.ts
Normal 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 { }
|
||||||
188
src/app/components/push-commands-dialog.component.html
Normal file
188
src/app/components/push-commands-dialog.component.html
Normal 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 <existing-image> {{ data.registryHost }}/<repository>:<tag></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 }}/<repository>:<tag></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 <your-image> {{ data.registryHost }}/{{ data.selectedRepo }}:<tag></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 }}:<tag></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>
|
||||||
138
src/app/components/push-commands-dialog.component.scss
Normal file
138
src/app/components/push-commands-dialog.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/components/push-commands-dialog.component.ts
Normal file
43
src/app/components/push-commands-dialog.component.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/models/registry.model.ts
Normal file
72
src/app/models/registry.model.ts
Normal 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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
199
src/app/services/docker-registry.service.ts
Normal file
199
src/app/services/docker-registry.service.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/environments/environment.ts
Normal file
3
src/environments/environment.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false
|
||||||
|
};
|
||||||
1
src/favicon.ico
Normal file
1
src/favicon.ico
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- Placeholder favicon.ico - Replace with actual favicon -->
|
||||||
17
src/index.html
Normal file
17
src/index.html
Normal 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
5
src/main.ts
Normal 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
1
src/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import 'zone.js';
|
||||||
332
src/styles.scss
Normal file
332
src/styles.scss
Normal 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
29
start-dev.bat
Normal 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
25
start-dev.sh
Normal 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
14
tsconfig.app.json
Normal 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
32
tsconfig.json
Normal 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
30
unraid-template.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user