Initial commit
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user