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

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;
}
}
}