added app project files

This commit is contained in:
Dragos 2021-04-07 14:26:28 +03:00
parent 991f5cabdc
commit 20ee57102e
297 changed files with 33967 additions and 0 deletions

13
app/.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
app/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/dist-server
/tmp
/out-tsc
# 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
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
server/node_modules/

1
app/README.md Normal file
View File

@ -0,0 +1 @@
https://medium.com/bb-tutorials-and-thoughts/how-to-develop-and-build-angular-app-with-nodejs-e24c40444421

196
app/angular.json Normal file
View File

@ -0,0 +1,196 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects":
{
"manta":
{
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics":
{
},
"architect":
{
"build":
{
"builder": "@angular-devkit/build-angular:browser",
"options":
{
"progress": false,
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets":
[
"src/assets",
"src/manifest.webmanifest",
"src/env.js"
],
"styles":
[
"./node_modules/bootstrap/dist/css/bootstrap.min.css",
"./node_modules/ngx-bootstrap/datepicker/bs-datepicker.css",
"./node_modules/ngx-toastr/toastr.css",
"src/styles/styles.scss",
"src/styles/icons.scss"
],
"scripts":
[
]
},
"configurations":
{
"production":
{
"fileReplacements":
[
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
}
}
},
"serve":
{
"builder": "@angular-devkit/build-angular:dev-server",
"options":
{
"browserTarget": "manta:build",
"proxyConfig": "proxy.conf.js"
},
"configurations":
{
"production":
{
"browserTarget": "manta:build:production"
}
}
},
"extract-i18n":
{
"builder": "@angular-devkit/build-angular:extract-i18n",
"options":
{
"browserTarget": "manta:build"
}
},
"test":
{
"builder": "@angular-devkit/build-angular:karma",
"options":
{
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js",
"styles":
[
"src/styles.css"
],
"scripts":
[
],
"assets":
[
"src/assets"
]
}
},
"lint":
{
"builder": "@angular-devkit/build-angular:tslint",
"options":
{
"tsConfig":
[
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude":
[
"**/node_modules/**"
]
}
},
"server":
{
"builder": "@angular-devkit/build-angular:server",
"options":
{
"outputPath": "dist-server",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.server.json"
},
"configurations":
{
"dev":
{
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": true
},
"production":
{
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false
}
}
}
}
},
"manta-e2e":
{
"root": "e2e/",
"projectType": "application",
"architect":
{
"e2e":
{
"builder": "@angular-devkit/build-angular:protractor",
"options":
{
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "manta:serve"
}
},
"lint":
{
"builder": "@angular-devkit/build-angular:tslint",
"options":
{
"tsConfig": "e2e/tsconfig.e2e.json",
"exclude":
[
"**/node_modules/**"
]
}
}
}
}
},
"defaultProject": "manta"
}

9
app/browserslist Normal file
View File

@ -0,0 +1,9 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For IE 9-11 support, please uncomment the last line of the file and adjust as needed
> 0.5%
last 2 versions
Firefox ESR
not dead
# IE 9-11

View File

@ -0,0 +1,28 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require("jasmine-spec-reporter");
exports.config = {
allScriptsTimeout: 11000,
specs: ["./src/**/*.e2e-spec.ts"],
capabilities: {
browserName: "chrome"
},
directConnect: true,
baseUrl: "http://localhost:4200/",
framework: "jasmine",
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require("ts-node").register({
project: require("path").join(__dirname, "./tsconfig.e2e.json")
});
jasmine
.getEnv()
.addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -0,0 +1,14 @@
import { AppPage } from './app.po';
describe('App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getMainHeading()).toEqual('Hello, world!');
});
});

11
app/e2e/src/app.po.ts Normal file
View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getMainHeading() {
return element(by.css('app-root h1')).getText();
}
}

13
app/e2e/tsconfig.e2e.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

29
app/ngsw-config.json Normal file
View File

@ -0,0 +1,29 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/*.css",
"/*.js",
"/manifest.webmanifest"
]
}
}, {
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
]
}

16038
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
app/package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "manta",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:ssr": "ng run manta:server:dev",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular-slider/ngx-slider": "^2.0.3",
"@angular/animations": "^11.0.5",
"@angular/cdk": "^11.0.3",
"@angular/common": "^11.0.5",
"@angular/compiler": "^11.0.5",
"@angular/core": "^11.0.5",
"@angular/forms": "^11.0.5",
"@angular/http": "^7.2.16",
"@angular/platform-browser": "^11.0.5",
"@angular/platform-browser-dynamic": "^11.0.5",
"@angular/platform-server": "^11.0.5",
"@angular/router": "^11.0.5",
"@angular/service-worker": "^11.0.5",
"@fortawesome/angular-fontawesome": "^0.4.0",
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-brands-svg-icons": "^5.12.1",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@nguniversal/module-map-ngfactory-loader": "^8.1.1",
"@ngx-translate/core": "^12.1.2",
"@swimlane/ngx-datatable": "^19.0.0",
"aspnet-prerendering": "^3.0.1",
"backoff-rxjs": "^6.5.7",
"bootstrap": "5.0.0-beta2",
"core-js": "^3.3.3",
"crypto-js": "^4.0.0",
"fuse.js": "^5.1.0",
"messageformat": "^2.3.0",
"ngx-autosize": "^1.7.4",
"ngx-bootstrap": "^6.2.0",
"ngx-clipboard": "^14.0.1",
"ngx-timeago": "^2.0.0",
"ngx-toastr": "^11.3.3",
"ngx-translate-messageformat-compiler": "^4.5.0",
"ngx-virtual-scroller": "^4.0.3",
"oidc-client": "^1.9.1",
"rxjs": "^6.5.3",
"ts-cacheable": "^1.0.4",
"zone.js": "0.9.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.1100.5",
"@angular/cli": "^11.0.5",
"@angular/compiler-cli": "^11.0.5",
"@angular/language-service": "^11.0.5",
"@types/crypto-js": "^4.0.1",
"@types/jasmine": "~3.4.4",
"@types/jasminewd2": "~2.0.8",
"@types/node": "~12.11.6",
"codelyzer": "^5.2.0",
"jasmine-core": "~3.5.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "^5.0.2",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~2.1.0",
"karma-jasmine": "~2.0.1",
"karma-jasmine-html-reporter": "^1.4.2",
"typescript": "4.0.5",
"webpack-dev-server": "^3.10.3"
},
"optionalDependencies": {
"protractor": "~5.4.2",
"ts-node": "~8.4.1",
"tslint": "~5.20.0"
}
}

8
app/proxy.conf.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
'/api': {
target: 'https://localhost:8443',
secure: false,
pathRewrite: {'^/api': ''},
logLevel: 'debug'
}
}

View File

@ -0,0 +1,91 @@
<form novalidate>
<fieldset [formGroup]="editorForm" [disabled]="working">
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<div class="content">
<h4>Update your profile</h4>
<div class="row g-3 mt-3">
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="firstName" formControlName="firstName" placeholder="First name" [appAutofocus]="true" [appAutofocusDelay]="600">
<label for="firstName">First name</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="lastName" formControlName="lastName" placeholder="Last name">
<label for="lastName">Last name</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="companyName" formControlName="companyName" placeholder="Company name">
<label for="companyName">Company name</label>
</div>
</div>
<div class="col-sm-10">
<div class="form-floating">
<input type="text" class="form-control" id="address" formControlName="address" placeholder="Address">
<label for="address">Address</label>
</div>
</div>
<div class="col-sm-2">
<div class="form-floating">
<input type="text" class="form-control" id="postalCode" formControlName="postalCode" placeholder="Postal code">
<label for="postalCode">Postal code</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="city" formControlName="city" placeholder="City">
<label for="city">City</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="state" formControlName="state" placeholder="State">
<label for="state">State</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control" id="country" formControlName="country" placeholder="Country">
<label for="country">Country</label>
</div>
</div>
<div class="col-sm-6">
<div class="form-floating">
<input type="tel" class="form-control" id="phone" formControlName="phone" placeholder="Phone">
<label for="phone">Phone</label>
</div>
</div>
<div class="col-sm-6">
<div class="form-floating">
<input type="email" class="form-control" id="email" formControlName="email" placeholder="Email">
<label for="email">Email</label>
</div>
</div>
<div class="col-12 mt-3">
<span class="form-check">
<input class="form-check-input" type="checkbox" id="cns" formControlName="cns">
<label class="form-check-label" for="cns">Use Container Name Service</label>
</span>
</div>
</div>
<div class="d-flex justify-content-end mt-5">
<button class="btn btn-link text-info me-3" (click)="close()">Close without saving</button>
<button class="btn btn-lg btn-info" (click)="saveChanges()">
<fa-icon icon="spinner" [pulse]="true" class="me-1" *ngIf="working"></fa-icon>
Save changes
</button>
</div>
</div>
</fieldset>
</form>

View File

@ -0,0 +1,75 @@
input
{
height: auto;
padding: 0.5rem;
}
.form-check
{
display: flex;
align-items: center;
padding: 0;
margin: 0 0 0 1.5rem;
cursor: pointer;
flex-grow: 1;
.form-check-input
{
margin-right: .5rem;
float: none;
width: 1.4em;
max-width: 1rem;
margin-bottom: .25rem;
cursor: inherit;
background-color: #0dc3e9;
border-color: #0dc3e9;
box-shadow: 0 0 0 1px rgb(12, 19, 33, .5) inset;
&:checked
{
background-color: #ff9c07;
border-color: #ff9c07;
&:not(:focus)
{
box-shadow: none;
}
}
&:checked[type=radio]
{
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230c1321'/%3e%3c/svg%3e");
}
&:checked[type=checkbox]
{
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230c1321' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
}
&:focus
{
box-shadow: 0 0 0 0.25rem rgba(13, 195, 233, .5);
}
&:checked:focus
{
box-shadow: 0 0 0 0.25rem rgba(255, 156, 7, .25);
}
}
.form-check-label
{
cursor: inherit;
width: 100%;
padding: .75rem 0;
font-family: "Bebas Neue", sans-serif;
font-size: 1.2rem;
}
}
.form-check-input:checked + .form-check-label,
.form-check-input:checked + .form-check-label .package-specs,
.form-check-input:checked + .form-check-label .h3
{
color: #ff9c07;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountEditorComponent } from './account-editor.component';
describe('AccountEditorComponent', () => {
let component: AccountEditorComponent;
let fixture: ComponentFixture<AccountEditorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AccountEditorComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AccountEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,101 @@
import { Component, OnInit } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
import { NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { AuthService } from '../../helpers/auth.service';
import { UserInfo } from '../models/user-info';
import { AccountService } from '../helpers/account.service';
import { ToastrService } from 'ngx-toastr';
@Component({
selector: 'app-account-editor',
templateUrl: './account-editor.component.html',
styleUrls: ['./account-editor.component.scss']
})
export class AccountEditorComponent implements OnInit
{
save = new Subject<any>();
loading: boolean;
working: boolean;
editorForm: FormGroup;
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly modalRef: BsModalRef,
private readonly router: Router,
private readonly fb: FormBuilder,
private readonly authService: AuthService,
private readonly accountService: AccountService,
private readonly toastr: ToastrService)
{ // When the user navigates away from this route, hide the modal
router.events
.pipe(
takeUntil(this.destroy$),
filter(e => e instanceof NavigationStart)
)
.subscribe(() => this.modalRef.hide());
authService.userInfoUpdated$.subscribe(this.createForm.bind(this));
}
// ----------------------------------------------------------------------------------------------------------------
private createForm(userInfo: UserInfo)
{
this.editorForm = this.fb.group(
{
id: [userInfo.id],
firstName: [userInfo.firstName],
lastName: [userInfo.lastName],
companyName: [userInfo.companyName],
address: [userInfo.address],
postalCode: [userInfo.postalCode],
city: [userInfo.city],
state: [userInfo.state],
country: [userInfo.country],
email: [userInfo.email],
phone: [userInfo.phone],
cns: [userInfo.triton_cns_enabled]
});
}
// ----------------------------------------------------------------------------------------------------------------
close(response?: any)
{
this.save.next(response);
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
saveChanges()
{
this.working = true;
const changes = this.editorForm.getRawValue();
const userInfo = new UserInfo();
Object.assign(userInfo, changes);
userInfo.triton_cns_enabled = changes.cns;
this.accountService.updateAccount(userInfo)
.subscribe(response =>
{
this.authService.userInfo = response;
this.close(response);
this.working = false;
}, err =>
{
this.toastr.error(err.error.message);
this.working = false;
});
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
}
}

View File

@ -0,0 +1,55 @@
<div *ngIf="userInfo" class="pt-3">
<div class="row">
<div class="col-sm-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4>My profile</h4>
<button class="btn btn-sm btn-outline-info" (click)="showEditor()">Update profile</button>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item">
Name: <b>{{ userInfo.firstName }} {{ userInfo.lastName }}</b>
</li>
<li class="list-group-item">
Username: <b>{{ userInfo.login }}</b>
</li>
<li class="list-group-item">
Email: <b>{{ userInfo.email }}</b>
</li>
<li class="list-group-item">
Phone: <b>{{ userInfo.phone }}</b>
</li>
<li class="list-group-item">
Container Name Service:
<span class="badge border ms-1 text-uppercase"
[ngClass]="userInfo.triton_cns_enabled ? 'border-success text-success' : 'danger-success text-danger'">
{{ userInfo.triton_cns_enabled ? 'enabled' : 'disabled' }}
</span>
</li>
</ul>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4>My SSH keys</h4>
<button class="btn btn-sm btn-outline-info" (click)="addSshKey()">Add SSH key</button>
</div>
<div class="card-body">
<ol class="list-group list-group-flush">
<li class="list-group-item" *ngFor="let userKey of userKeys">
{{ userKey.name }}: <b class="text-uppercase">{{ userKey.fingerprint }}</b>
</li>
</ol>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
ul, ol
{
background-color: rgba(16, 21, 39, .75);
height: 100%;
}
h4
{
margin-bottom: 0;
}
.list-group-item
{
background: none;
padding: 1rem;
border-color: rgb(61, 94, 142, .25);
color: #5a8cd8;
b
{
color: #ff9c07;
}
}
.card
{
border: 1px solid rgba(0, 0, 0, 0.5);
background-color: rgba(16, 21, 39, 0.5);
box-shadow: 0 0 0 2px #0b2b51, 0 0 2px 4px #0b284b, 0 0 10px 3px #0e162a;
transition: box-shadow 0.15s ease-out;
height: 100%;
&:hover
{
box-shadow: 0 0 0 2px #0b2b51, 0 0 2px 4px rgba(18, 203, 240, .4), 0 0 10px 3px #0e162a;
}
.card-body
{
padding: 0;
}
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AccountComponent } from './account.component';
describe('AccountComponent', () => {
let component: AccountComponent;
let fixture: ComponentFixture<AccountComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ AccountComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AccountComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,90 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { AccountService } from './helpers/account.service';
import { AuthService } from '../helpers/auth.service';
import { first, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { UserInfo } from './models/user-info';
import { UserKey } from './models/user-key';
import { BsModalService } from 'ngx-bootstrap/modal';
import { AccountEditorComponent } from './account-editor/account-editor.component';
import { ToastrService } from 'ngx-toastr';
import { SshKeyEditorComponent } from './ssh-key-editor/ssh-key-editor.component';
@Component({
selector: 'app-account',
templateUrl: './account.component.html',
styleUrls: ['./account.component.scss']
})
export class AccountComponent implements OnInit, OnDestroy
{
userInfo: UserInfo;
userKeys: UserKey[];
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly accountService: AccountService,
private readonly authService: AuthService,
private readonly modalService: BsModalService,
private readonly toastr: ToastrService)
{
//accountService.getUsers().subscribe(x => console.log(x));
accountService.getUserLimits().subscribe(x => console.log(x));
authService.userInfoUpdated$
.pipe(takeUntil(this.destroy$))
.subscribe(x => this.userInfo = x);
accountService.getKeys().subscribe(x => this.userKeys = x);
}
// ----------------------------------------------------------------------------------------------------------------
showEditor()
{
const modalConfig = {
ignoreBackdropClick: true,
keyboard: false,
animated: true,
initialState: {}
};
const modalRef = this.modalService.show(AccountEditorComponent, modalConfig);
modalRef.setClass('modal-lg');
}
// ----------------------------------------------------------------------------------------------------------------
addSshKey()
{
const modalConfig = {
ignoreBackdropClick: true,
keyboard: false,
animated: true,
initialState: {}
};
const modalRef = this.modalService.show(SshKeyEditorComponent, modalConfig);
modalRef.content.save.pipe(first()).subscribe(x => this.userKeys = [...this.userKeys, x]);
// this.accountService.addKey('test',
// 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEAzf7Cbu8tPvxgwG3MhXK959F7TtsSCQQXb3jSPAJtQT+CltA+OYLod/ojclfQfnutIHUpqq6PsCD/nhxiF2JYkKWve7olJV6akvXQOGNLqRdXTcEouUhevLAQV3sB+YNvjr5FRpspNK8prAn7UU4vyZhCKBT8VAgwkio3u8eR/26XDNow1C9NXC6P+2BYWjjKbJCI41XpLFIzsmHBw+XZox+IbVg8mcVsWfdhEHRDyxM1HgvOKU9vkCwigmww9nsIatSQuM0jCtohQRkddc2DlfKieBmpeC/VqNoWE77iei/nVOcgIaLjwwevdCGHhwtSBmkE+W14JCwFbzl0yThL2w== rsa-key-20210314',
// 'ba:04:55:94:64:24:75:a4:b2:60:e5:bf:77:19:df:34')
// .subscribe(response => this.userKeys = [...this.userKeys, response],
// err =>
// {
// this.toastr.error(err.error.message)
// });
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit()
{
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.destroy$.next();
}
}

View File

@ -0,0 +1,48 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared.module';
import { RouterModule } from '@angular/router';
import { TranslateModule, TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { TranslateLoader } from '@ngx-translate/core';
import { WebpackTranslateLoader } from '../helpers/webpack-translate-loader.service';
import { TranslateCompiler } from '@ngx-translate/core';
import { TranslateMessageFormatCompiler } from 'ngx-translate-messageformat-compiler';
import { AccountComponent } from './account.component';
import { AccountEditorComponent } from './account-editor/account-editor.component';
import { SshKeyEditorComponent } from './ssh-key-editor/ssh-key-editor.component';
@NgModule({
declarations: [AccountComponent, AccountEditorComponent, SshKeyEditorComponent],
imports: [
SharedModule,
RouterModule.forChild([
{
path: '',
component: AccountComponent
}
]),
TranslateModule.forChild({
loader: {
provide: TranslateLoader,
//useClass: WebpackTranslateLoader
useFactory: () => new WebpackTranslateLoader('account')
},
compiler: {
provide: TranslateCompiler,
useFactory: () => new TranslateMessageFormatCompiler()
},
isolate: true
})
]
})
export class AccountModule
{
constructor(private readonly translate: TranslateService)
{
translate.use(translate.store.currentLang);
translate.store.onLangChange.subscribe((event: LangChangeEvent) => translate.use(event.lang));
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AccountService } from './account.service';
describe('AccountService', () => {
let service: AccountService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AccountService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { UserInfo } from '../models/user-info';
import { Observable } from 'rxjs';
import { UserKey } from '../models/user-key';
@Injectable({
providedIn: 'root'
})
export class AccountService
{
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly httpClient: HttpClient) { }
// ----------------------------------------------------------------------------------------------------------------
getUserInfo(): Observable<UserInfo>
{
return this.httpClient.get<UserInfo>(`/api/my`);
}
// ----------------------------------------------------------------------------------------------------------------
updateAccount(userInfo: UserInfo): Observable<UserInfo>
{
return this.httpClient.post<UserInfo>(`/api/my`, userInfo);
}
// ----------------------------------------------------------------------------------------------------------------
getUserLimits()
{
return this.httpClient.get(`/api/my/limits`);
}
// ----------------------------------------------------------------------------------------------------------------
getKeys(): Observable<UserKey[]>
{
return this.httpClient.get<UserKey[]>(`/api/my/keys`);
}
// ----------------------------------------------------------------------------------------------------------------
addKey(name: string, key: string, fingerprint: string): Observable<UserKey>
{
return this.httpClient.post<UserKey>(`/api/my/keys`, { name, key, fingerprint });
}
// ----------------------------------------------------------------------------------------------------------------
deleteKey(name: string)
{
return this.httpClient.delete(`/api/my/keys/${name}`);
}
}

View File

@ -0,0 +1,6 @@
import { User } from '../../security/models/user';
export class UserInfo extends User
{
triton_cns_enabled: boolean;
}

View File

@ -0,0 +1,6 @@
export class UserKey
{
fingerprint: string;
key: string;
name: string;
}

View File

@ -0,0 +1,21 @@
<form novalidate>
<fieldset [formGroup]="editorForm" [disabled]="working" *ngIf="editorForm">
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<div class="content">
<h4 class="mb-3">SSH key editor</h4>
<div class="mt-3">
<input type="text" class="form-control" formControlName="name" placeholder="Name" [appAutofocus]="true" [appAutofocusDelay]="600">
<textarea rows="4" class="form-control" formControlName="key" placeholder="SSH key"></textarea>
<input type="text" class="form-control" formControlName="fingerprint" placeholder="Fingerprint">
</div>
<div class="d-flex justify-content-end align-items-center mt-5">
<button class="btn btn-info" (click)="saveChanges()" [disabled]="editorForm.invalid">Save changes</button>
</div>
</div>
</fieldset>
</form>

View File

@ -0,0 +1,18 @@
p
{
color: #ff9c07;
font-size: 1.15rem;
}
.form-control
{
+ .form-control
{
margin-top: .5rem;
}
}
textarea
{
border-radius: 2rem;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SshKeyEditorComponent } from './ssh-key-editor.component';
describe('SshKeyEditorComponent', () => {
let component: SshKeyEditorComponent;
let fixture: ComponentFixture<SshKeyEditorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ SshKeyEditorComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SshKeyEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,83 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
import { NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { AccountService } from '../helpers/account.service';
import { ToastrService } from 'ngx-toastr';
@Component({
selector: 'app-ssh-key-editor',
templateUrl: './ssh-key-editor.component.html',
styleUrls: ['./ssh-key-editor.component.scss']
})
export class SshKeyEditorComponent implements OnInit, OnDestroy
{
save = new Subject<any>();
editorForm: FormGroup;
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly accountService: AccountService,
private readonly modalRef: BsModalRef,
private readonly router: Router,
private readonly fb: FormBuilder,
private readonly toastr: ToastrService)
{ // When the user navigates away from this route, hide the modal
router.events
.pipe(
takeUntil(this.destroy$),
filter(e => e instanceof NavigationStart)
)
.subscribe(() => this.modalRef.hide());
}
// ----------------------------------------------------------------------------------------------------------------
private createForm()
{
this.editorForm = this.fb.group(
{
name: [null, Validators.required],
key: [null, Validators.required],
fingerprint: [null, Validators.required]
});
}
// ----------------------------------------------------------------------------------------------------------------
close()
{
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
saveChanges()
{
const sshKey = this.editorForm.getRawValue();
this.accountService.addKey(sshKey.name, sshKey.key, sshKey.fingerprint)
.subscribe(response =>
{
this.save.next(response);
this.modalRef.hide();
},
err =>
{
this.toastr.error(err.error.message);
});
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit()
{
this.createForm();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.destroy$.next();
}
}

View File

@ -0,0 +1,88 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { UnauthorizedComponent } from './pages/unauthorized/unauthorized.component';
import { NotFoundComponent } from './pages/not-found/not-found.component';
import { AuthGuardService } from './helpers/auth-guard.service';
const appRoutes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard',
},
{
path: 'file-manager',
loadChildren: () => import('./file-manager/file-manager.module').then(x => x.FileManagerModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
data:
{
title: 'fileManager.title',
subTitle: 'fileManager.subTitle',
icon: 'folder'
}
},
{
path: 'dashboard',
loadChildren: () => import('./instances/instances.module').then(x => x.InstancesModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
},
{
path: 'catalog',
loadChildren: () => import('./catalog/catalog.module').then(x => x.CatalogModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
},
{
path: 'volumes',
loadChildren: () => import('./volumes/volumes.module').then(x => x.VolumesModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
},
{
path: 'networking',
loadChildren: () => import('./networking/networking.module').then(x => x.NetworkingModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
},
{
path: 'security',
loadChildren: () => import('./security/security.module').then(x => x.SecurityModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
data:
{
title: 'security.title',
subTitle: 'security.subTitle',
icon: 'shield-alt'
}
},
{
path: 'account',
loadChildren: () => import('./account/account.module').then(x => x.AccountModule),
canActivate: [AuthGuardService],
canLoad: [AuthGuardService],
data:
{
title: 'account.title',
subTitle: 'account.subTitle',
icon: 'user-cog'
}
},
{
path: 'unauthorized',
component: UnauthorizedComponent
},
{
path: '**',
component: NotFoundComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(appRoutes, { scrollPositionRestoration: 'enabled' })],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@ -0,0 +1,57 @@
<main [class.menu-open]="menuVisibility">
<div class="module-loading-spinner" *ngIf="showProgress">
<div class="spinner"></div>
</div>
<div class="backdrop" *ngIf="menuVisibility" (click)="menuVisibility = false"></div>
<div class="menu">
<app-nav-menu (navigate)="menuVisibility = false"></app-nav-menu>
</div>
<div class="pusher">
<div class="content">
<div class="content-inner d-flex flex-column h-100">
<header class="sticky-top">
<nav class="navbar w-100 px-2">
<div class="brand d-flex align-items-center w-75">
<a href="javascript:void(0)" class="icon" (click)="menuVisibility = !menuVisibility">
<fa-icon [fixedWidth]="true" icon="outdent"></fa-icon>
{{ title | translate }}
</a>
</div>
<div *ngIf="userInfo" class="btn-group" role="group" dropdown placement="bottom right">
<button id="accountMenuDropdown" type="button" class="btn btn-link dropdown-toggle" dropdownToggle aria-expanded="false">
<fa-icon icon="user" size="sm" class="me-1 align-middle"></fa-icon>
<span class="d-none d-sm-inline-block">{{ userInfo.firstName }} {{ userInfo.lastName }}</span>
</button>
<ul *dropdownMenu class="dropdown-menu dropdown-menu-right" aria-labelledby="accountMenuDropdown">
<li>
<a class="dropdown-item" [routerLink]="['./account']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon icon="user-cog"></fa-icon>
Account details
</a>
</li>
<li class="dropdown-divider"></li>
<li>
<button class="dropdown-item" (click)="logOff()">
<fa-icon icon="power-off"></fa-icon>
Log off
</button>
</li>
</ul>
</div>
</nav>
</header>
<div class="no-overflow flex-grow-1">
<div class="h-100">
<router-outlet></router-outlet>
</div>
</div>
</div>
</div>
</div>
</main>

View File

@ -0,0 +1,200 @@
:host
{
height: 100%;
padding: 1rem;
display: block;
}
.module-loading-spinner
{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgb(9, 11, 23);
opacity: .5;
z-index: 1030;
}
main,
.pusher,
.content
{
height: 100%;
}
main
{
height: 100%;
border-radius: .5rem;
box-shadow: 0 0 0 6px rgba(38, 43, 80, .32), 0 0 0 11px rgba(26, 31, 60, .52), 0 0 0 15px rgba(17, 21, 48, .35);
perspective: 1500px;
position: relative;
position: relative;
overflow: hidden;
&:after
{
content: '';
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: url('../assets/images/bg.jpg') #111530 no-repeat;
background-size: cover;
filter: blur(40px);
-webkit-filter: blur(40px);
z-index: -1;
opacity: .7;
}
}
.content
{
border-radius: .5rem;
background-color: rgba(17, 21, 48, .5);
border: 1px solid rgba(0, 0, 0, .3);
}
.content,
.content-inner
{
position: relative;
}
.backdrop
{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.pusher
{
position: relative;
left: 0;
height: 100%;
perspective: 1000px;
transition: transform .5s;
transform-style: preserve-3d;
box-shadow: 0 0 4px -2px #FFF, 3rem 1rem 3rem -1rem rgba(0, 0, 0, 0.3), 3px 2px 0 2px rgba(13, 16, 37, .4), 4px 3px 1px 2px rgba(0, 0, 0, 0.2);
border-radius: .5rem;
&:before
{
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
background: linear-gradient(208deg, rgba(255, 255, 255, 0) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.3) 100%);
border-radius: .5rem;
content: '';
opacity: 0;
transition: opacity .5s;
pointer-events: none;
z-index: 1030;
}
}
.menu
{
position: absolute;
top: 0;
left: 0;
visibility: hidden;
min-width: 275px;
height: 100%;
background-color: rgba(16,21,39, .8);
transition: all .5s;
z-index: 2;
opacity: 1;
transform: translate3d(-100%, 0, 0);
&:after
{
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,.2);
content: '';
opacity: 1;
transition: all .5s;
display: none;
}
}
.menu-open
{
.menu
{
visibility: visible;
transform: translate3d(0, 0, 0);
box-shadow: 2rem 0 2rem -1rem rgba(0, 0, 0, .3);
&:after
{
width: 0;
height: 0;
opacity: 0;
transition: opacity .5s, width .1s .5s, height .1s .5s;
}
}
.pusher
{
transform: translate3d(30px, 0, -600px) rotateY(-20deg);
pointer-events: none;
&:before
{
width: 100%;
height: 100%;
opacity: 1;
transform: rotate(0deg);
}
}
}
header nav
{
top: 0;
}
.brand
{
font-family: 'Bebas Neue', sans-serif;
font-size: 2rem;
line-height: 1;
color: #ff9c07;
.icon
{
padding: .5rem .25rem;
margin: 0 .5rem .15rem 0;
line-height: 0;
color: #ff9c07;
text-decoration: none;
}
p
{
margin-bottom: 0;
font-variant: normal;
font-size: 1rem;
opacity: .75;
}
}
#accountMenuDropdown
{
color: #FFF;
text-decoration: none;
}

View File

@ -0,0 +1,82 @@
import { Component } from '@angular/core';
import { Router, RouteConfigLoadStart, RouteConfigLoadEnd, NavigationStart, NavigationEnd, ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { BsLocaleService } from 'ngx-bootstrap/datepicker';
import { filter, map, mergeMap } from 'rxjs/operators';
import { TokenService } from './helpers/token.service';
import { AuthService } from './helpers/auth.service';
import { UserInfo } from './account/models/user-info';
@Component({
selector: 'app-root',
styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html'
})
export class AppComponent
{
showProgress = false;
menuVisibility = false;
title: string;
subTitle: string;
icon: string | string[];
userInfo: UserInfo;
routeChanged: boolean;
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly router: Router,
private readonly activatedRoute: ActivatedRoute,
private readonly translate: TranslateService,
private readonly localeService: BsLocaleService,
private readonly authService: AuthService)
{
// This language will be used as a fallback when a translation isn't found in the current language
translate.setDefaultLang('en');
translate.use('en');
// NgxBootstrap locale
this.localeService.use('en');
router.events
.pipe(filter(x => x instanceof RouteConfigLoadStart || x instanceof RouteConfigLoadEnd))
.subscribe(x =>
{
this.showProgress = x instanceof RouteConfigLoadStart;
});
router.events
.pipe(filter(x => x instanceof NavigationStart))
.subscribe(x => this.routeChanged = false);
router.events
.pipe(
filter(x => x instanceof NavigationEnd),
map(() => activatedRoute),
map(route =>
{
while (route.firstChild)
route = route.firstChild;
return route;
}),
filter(route => route.outlet === 'primary'),
mergeMap(route => route.data)
)
.subscribe(x =>
{
this.title = x.title;
this.subTitle = x.subTitle;
this.icon = x.icon;
this.routeChanged = true;
});
authService.userInfoUpdated$.subscribe(userInfo => this.userInfo = userInfo);
}
// ----------------------------------------------------------------------------------------------------------------
logOff()
{
this.authService.logout();
}
}

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

@ -0,0 +1,69 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { registerLocaleData } from '@angular/common';
import localeEn from '@angular/common/locales/en';
import { TranslateModule, TranslateCompiler, TranslateLoader } from '@ngx-translate/core';
import { MESSAGE_FORMAT_CONFIG, TranslateMessageFormatCompiler } from 'ngx-translate-messageformat-compiler';
import { WebpackTranslateLoader } from './helpers/webpack-translate-loader.service';
import { SharedModule } from './shared.module';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { DashboardComponent } from './pages/dashboard/dashboard.component';
import { UnauthorizedComponent } from './pages/unauthorized/unauthorized.component';
import { NotFoundComponent } from './pages/not-found/not-found.component';
import { NavMenuComponent } from './components/nav-menu/nav-menu.component';
import { AuthInterceptorService } from './helpers/auth-interceptor.service';
@NgModule({
declarations: [
AppComponent,
DashboardComponent,
UnauthorizedComponent,
NotFoundComponent,
NavMenuComponent
],
imports: [
BrowserAnimationsModule,
BrowserModule.withServerTransition({ appId: 'manta-portal' }),
HttpClientModule,
AppRoutingModule,
SharedModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: () => new WebpackTranslateLoader()
},
compiler: {
provide: TranslateCompiler,
useFactory: () => new TranslateMessageFormatCompiler()
}
})
],
providers: [
{
provide: MESSAGE_FORMAT_CONFIG,
useValue: {
locales: ['en', 'ro']
}
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule
{
constructor()
{
registerLocaleData(localeEn, 'en');
}
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
@NgModule({
imports: [AppModule, ServerModule, ModuleMapLoaderModule],
bootstrap: [AppComponent]
})
export class AppServerModule { }

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CatalogComponent } from './catalog.component';
describe('CatalogComponent', () => {
let component: CatalogComponent;
let fixture: ComponentFixture<CatalogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CatalogComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CatalogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,21 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-catalog',
templateUrl: './catalog.component.html',
styleUrls: ['./catalog.component.scss']
})
export class CatalogComponent implements OnInit
{
images: any[];
// ----------------------------------------------------------------------------------------------------------------
constructor()
{
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
}
}

View File

@ -0,0 +1,99 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared.module';
import { RouterModule } from '@angular/router';
import { TranslateModule, TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { TranslateLoader } from '@ngx-translate/core';
import { WebpackTranslateLoader } from '../helpers/webpack-translate-loader.service';
import { TranslateCompiler } from '@ngx-translate/core';
import { TranslateMessageFormatCompiler } from 'ngx-translate-messageformat-compiler';
import { CatalogComponent } from './catalog.component';
import { CustomImagesComponent } from './custom-images/custom-images.component';
import { DockerImagesComponent } from './docker-images/docker-images.component';
import { DockerRegistryComponent } from './docker-registry/docker-registry.component';
import { DockerImageEditorComponent } from './docker-image-editor/docker-image-editor.component';
import { DockerRegistryEditorComponent } from './docker-registry-editor/docker-registry-editor.component';
import { CustomImageEditorComponent } from './custom-image-editor/custom-image-editor.component';
@NgModule({
declarations: [
CatalogComponent,
CustomImagesComponent,
DockerImagesComponent,
DockerRegistryComponent,
DockerImageEditorComponent,
DockerRegistryEditorComponent
],
imports: [
SharedModule,
RouterModule.forChild([
{
path: '',
component: CatalogComponent,
children: [
{
path: '',
redirectTo: 'custom-images'
},
{
path: 'custom-images',
component: CustomImagesComponent,
data:
{
title: 'catalog.customImages.title',
subTitle: 'catalog.customImages.subTitle',
icon: 'layer-group'
}
},
{
path: 'docker-images',
component: DockerImagesComponent,
data:
{
title: 'catalog.dockerImages.title',
subTitle: 'catalog.dockerImages.subTitle',
icon: ['fab', 'docker']
}
},
{
path: 'docker-registry',
component: DockerRegistryComponent,
data:
{
title: 'catalog.dockerRegistry.title',
subTitle: 'catalog.dockerRegistry.subTitle',
icon: ['fab', 'docker']
}
}]
}
]),
TranslateModule.forChild({
loader: {
provide: TranslateLoader,
//useClass: WebpackTranslateLoader
useFactory: () => new WebpackTranslateLoader('catalog')
},
compiler: {
provide: TranslateCompiler,
useFactory: () => new TranslateMessageFormatCompiler()
},
isolate: true
})
],
entryComponents: [
DockerImageEditorComponent,
DockerRegistryEditorComponent,
CustomImageEditorComponent
]
})
export class CatalogModule
{
constructor(private readonly translate: TranslateService)
{
translate.use(translate.store.currentLang);
translate.store.onLangChange.subscribe((event: LangChangeEvent) => translate.use(event.lang));
}
}

View File

@ -0,0 +1,24 @@
<form novalidate>
<fieldset [formGroup]="editorForm">
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<div class="content">
<h4 class="mb-3">Create image from machine</h4>
<p class="my-2">Fill in the name and version for a new image based on the "{{ instance.name }}" machine</p>
<input type="text" class="form-control mb-3" formControlName="name" placeholder="Image name" [appAutofocus]="true" [appAutofocusDelay]="600">
<input type="text" class="form-control mb-3" formControlName="version" placeholder="Image version">
<textarea class="form-control" formControlName="description" placeholder="Description (optional)">
</textarea>
<div class="d-flex justify-content-end align-items-center mt-5">
<button class="btn btn-info" (click)="saveChanges()" [disabled]="editorForm.invalid">Create image</button>
</div>
</div>
</fieldset>
</form>

View File

@ -0,0 +1,30 @@
h4
{
color: #00dcff;
}
p
{
color: #ff9c07;
font-size: 1.15rem;
}
input, textarea
{
height: auto;
padding: .5rem 1rem;
border-radius: 3rem;
}
input, input:focus, input:active,
textarea, textarea:focus, textarea:active
{
background: transparent;
border-color: #00e7ff;
color: #ff9c07;
}
textarea
{
resize: none;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomImageEditorComponent } from './custom-image-editor.component';
describe('CustomImageEditorComponent', () => {
let component: CustomImageEditorComponent;
let fixture: ComponentFixture<CustomImageEditorComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ CustomImageEditorComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CustomImageEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,65 @@
import { Component, OnInit, Input } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
import { NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-custom-image-editor',
templateUrl: './custom-image-editor.component.html',
styleUrls: ['./custom-image-editor.component.scss']
})
export class CustomImageEditorComponent implements OnInit
{
@Input()
instance: any;
save = new Subject<any>();
editorForm: FormGroup;
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly modalRef: BsModalRef,
private readonly router: Router,
private readonly fb: FormBuilder)
{ // When the user navigates away from this route, hide the modal
router.events
.pipe(
takeUntil(this.destroy$),
filter(e => e instanceof NavigationStart)
)
.subscribe(() => this.modalRef.hide());
}
// ----------------------------------------------------------------------------------------------------------------
private createForm()
{
this.editorForm = this.fb.group(
{
name: [null, Validators.required],
version: [null, Validators.required],
description: [null]
});
}
// ----------------------------------------------------------------------------------------------------------------
close()
{
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
saveChanges()
{
this.save.next(this.editorForm.getRawValue());
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
this.createForm();
}
}

View File

@ -0,0 +1,128 @@
<div class="d-flex flex-column h-100 pb-3">
<div class="container text-center mt-1" [formGroup]="editorForm">
<div class="btn-toolbar pt-2">
<span class="d-none d-sm-block flex-grow-1"></span>
<ng-container *ngIf="images && images.length">
<div class="input-group input-group-pill flex-grow-1 flex-grow-sm-0 me-sm-3 w-sm-auto w-100">
<input type="text" class="form-control" placeholder="Search by name..." formControlName="searchTerm" appAlphaOnly="^[A-Za-z0-9_-]+$">
<button class="btn btn-outline-info" type="button" (click)="clearSearch()" [disabled]="!editorForm.get('searchTerm').value"
tooltip="Clear search" container="body" placement="top" [adaptivePosition]="false">
<fa-icon icon="times" size="sm" [fixedWidth]="true"></fa-icon>
</button>
</div>
<div class="btn-group flex-grow-1 flex-grow-sm-0 w-sm-auto w-100" dropdown placement="bottom left">
<button class="btn btn-outline-info dropdown-toggle" dropdownToggle>
Sort by
<b *ngIf="editorForm.get('sortProperty').value === 'name'">name</b>
<b *ngIf="editorForm.get('sortProperty').value === 'description'">description</b>
<b *ngIf="editorForm.get('sortProperty').value === 'os'">operating system</b>
<b *ngIf="editorForm.get('sortProperty').value === 'type'">type</b>
<b *ngIf="editorForm.get('sortProperty').value === 'state'">status</b>
</button>
<ul id="dropdown-split" *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu">
<li role="menuitem">
<button class="dropdown-item" [class.active]="editorForm.get('sortProperty').value === 'name'" (click)="setSortProperty('name')">
Name
</button>
</li>
<li role="menuitem">
<button class="dropdown-item" [class.active]="editorForm.get('sortProperty').value === 'description'" (click)="setSortProperty('description')">
Description
</button>
</li>
<li role="menuitem">
<button class="dropdown-item" [class.active]="editorForm.get('sortProperty').value === 'os'" (click)="setSortProperty('os')">
Operating system
</button>
</li>
<li role="menuitem">
<button class="dropdown-item" [class.active]="editorForm.get('sortProperty').value === 'type'" (click)="setSortProperty('type')">
Type
</button>
</li>
<li role="menuitem">
<button class="dropdown-item" [class.active]="editorForm.get('sortProperty').value === 'status'" (click)="setSortProperty('status')">
Status
</button>
</li>
</ul>
</div>
</ng-container>
</div>
<div class="spinner-border text-center text-info text-faded" role="status" *ngIf="loadingIndicator">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="overflow-auto">
<div class="container my-4">
<div class="table-responsive" *ngIf="!loadingIndicator">
<p *ngIf="!images.length" class="text-center text-info text-faded p-3 mb-0">
You don't have any custom images yet
</p>
<table class="table table-hover" *ngIf="images.length">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>OS</th>
<th>Type</th>
<th>Publish date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let image of listItems">
<td>
{{ image.name }}
</td>
<td>
<div class="text-truncate">{{ image.description }}</div>
</td>
<td class="text-uppercase">
{{ image.os }}
</td>
<td class="text-uppercase">
{{ image.type }}
</td>
<td>
{{ image.published_at ? (image.published_at | timeago) : '' }}
</td>
<td>
<span class="badge" [ngClass]="image.state === 'active' ? 'bg-success' : 'bg-warning text-dark'">{{ image.state }}</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" dropdown placement="bottom right" container="body" [isDisabled]="image.working">
<button class="btn btn-link text-info" dropdownToggle
tooltip="More options" container="body" placement="top" [adaptivePosition]="false">
<fa-icon icon="ellipsis-v" [fixedWidth]="true" size="sm"></fa-icon>
</button>
<ul id="dropdown-split" *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="button-split">
<li role="menuitem">
<button class="dropdown-item" (click)="deleteCustomImage(image)">
<fa-icon icon="trash" [fixedWidth]="true"></fa-icon>
Delete this image
</button>
</li>
</ul>
</div>
</td>
</tr>
</tbody>
<tfoot *ngIf="!listItems.length">
<tr>
<td colspan="7" class="text-uppercase">
No images match your search criteria
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,45 @@
.table-responsive
{
background-color: rgba(16, 21, 39, 0.75);
box-shadow: 0 0 0 2px #0b2b51, 0 0 2px 4px #0b284b, 0 0 10px 3px #0e162a;
transition: box-shadow 0.15s ease-out;
border-radius: .25rem;
&:hover
{
box-shadow: 0 0 0 2px #0b2b51, 0 0 2px 4px rgb(18 203 240 / 40%), 0 0 10px 3px #0e162a;
}
.rule
{
text-transform: uppercase;
color: #3d5e8e;
}
.highlight
{
color: #8881ff;
}
b, .strong
{
color: #ff9c07;
font-weight: normal;
}
.text-truncate
{
max-width: 350px;
}
.inline-list-item + .inline-list-item
{
padding-left: .25rem;
&:before
{
content: attr(text);
color: #3d5e8e;
}
}
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CustomImagesComponent } from './custom-images.component';
describe('CustomImagesComponent', () => {
let component: CustomImagesComponent;
let fixture: ComponentFixture<CustomImagesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CustomImagesComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CustomImagesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,191 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ColumnMode, SelectionType } from '@swimlane/ngx-datatable';
import { CatalogService } from '../helpers/catalog.service';
import { AuthService } from '../../helpers/auth.service';
import { debounceTime, distinctUntilChanged, filter, first, map, switchMap, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { ToastrService } from 'ngx-toastr';
import { CatalogImage } from '../models/image';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
import Fuse from 'fuse.js';
import { sortArray } from '../../helpers/utils.service';
import { BsModalService } from 'ngx-bootstrap/modal';
import { ConfirmationDialogComponent } from '../../components/confirmation-dialog/confirmation-dialog.component';
@Component({
selector: 'app-custom-images',
templateUrl: './custom-images.component.html',
styleUrls: ['./custom-images.component.scss']
})
export class CustomImagesComponent implements OnInit, OnDestroy
{
images: CatalogImage[] = [];
listItems: CatalogImage[] = [];
editorForm: FormGroup;
loadingIndicator = true;
private destroy$ = new Subject();
private readonly fuseJsOptions: {};
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly catalogService: CatalogService,
private readonly modalService: BsModalService,
private readonly authService: AuthService,
private readonly toastr: ToastrService,
private readonly fb: FormBuilder)
{
// Configure FuseJs
this.fuseJsOptions = {
includeScore: false,
minMatchCharLength: 2,
includeMatches: true,
shouldSort: false,
threshold: .3, // Lower value means a more exact search
keys: [
{ name: 'name', weight: .9 },
{ name: 'description', weight: .8 },
{ name: 'os', weight: .7 },
{ name: 'type', weight: .7 }
]
};
this.createForm();
}
// ----------------------------------------------------------------------------------------------------------------
private createForm()
{
this.editorForm = this.fb.group(
{
searchTerm: [''],
sortProperty: ['name']
});
this.editorForm.get('searchTerm').valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(() => this.applyFiltersAndSort());
this.editorForm.get('sortProperty').valueChanges
.pipe(
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(() => this.applyFiltersAndSort());
}
// ----------------------------------------------------------------------------------------------------------------
private applyFiltersAndSort()
{
let listItems: CatalogImage[] = null;
const searchTerm = this.editorForm.get('searchTerm').value;
if (searchTerm.length >= 2)
{
const fuse = new Fuse(this.images, this.fuseJsOptions);
const fuseResults = fuse.search(searchTerm);
listItems = fuseResults.map(x => x.item as CatalogImage);
}
if (!listItems)
listItems = [...this.images];
this.listItems = sortArray(listItems, this.editorForm.get('sortProperty').value);
}
// ----------------------------------------------------------------------------------------------------------------
setSortProperty(propertyName: string)
{
this.editorForm.get('sortProperty').setValue(propertyName);
}
// ----------------------------------------------------------------------------------------------------------------
clearSearch()
{
this.editorForm.get('searchTerm').setValue('');
}
// ----------------------------------------------------------------------------------------------------------------
private getCustomImages()
{
this.loadingIndicator = true;
this.authService.userInfoUpdated$
.pipe(
takeUntil(this.destroy$),
filter(userInfo => userInfo != null),
switchMap(userInfo => this.catalogService.getCustomImages(userInfo.id))
)
.subscribe(images =>
{
this.images = images;
this.applyFiltersAndSort();
this.loadingIndicator = false
}, err =>
{
const errorDetails = err.error?.message ? `(${err.error.message})` : '';
this.toastr.error(`Failed to retrieve the list of custom images ${errorDetails}`);
this.loadingIndicator = false;
});
}
// ----------------------------------------------------------------------------------------------------------------
deleteCustomImage(image: CatalogImage)
{
const modalConfig = {
ignoreBackdropClick: true,
keyboard: false,
animated: true,
initialState: {
prompt: `Are you sure you wish to permanently delete the "${image.name}" image?`,
confirmButtonText: 'Yes, delete this image',
declineButtonText: 'No, keep it',
confirmByDefault: false
}
};
const modalRef = this.modalService.show(ConfirmationDialogComponent, modalConfig);
modalRef.content.confirm.pipe(first()).subscribe(() =>
{
this.toastr.info(`Removing machine "${image.name}"...`);
this.catalogService.deleteImage(image.id)
.subscribe(() =>
{
const index = this.images.findIndex(i => i.id === image.id);
if (index >= 0)
this.images.splice(index, 1);
this.applyFiltersAndSort();
this.toastr.info(`The image "${image.name}" has been removed`);
},
err =>
{
this.toastr.error(`Failed to delete the "${image.name}" image ${err.error.message}`);
});
});
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
this.getCustomImages();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.destroy$.next();
}
}

View File

@ -0,0 +1 @@
<p>docker-image-editor works!</p>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DockerImageEditorComponent } from './docker-image-editor.component';
describe('DockerImageEditorComponent', () => {
let component: DockerImageEditorComponent;
let fixture: ComponentFixture<DockerImageEditorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DockerImageEditorComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DockerImageEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-docker-image-editor',
templateUrl: './docker-image-editor.component.html',
styleUrls: ['./docker-image-editor.component.scss']
})
export class DockerImageEditorComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,57 @@
<ngx-datatable [rows]="rows" [headerHeight]="48" [footerHeight]="46" [rowHeight]="40"
[scrollbarV]="true" [scrollbarH]="false" [selectionType]="selectionType.checkbox" [columnMode]="columnMode.flex"
[loadingIndicator]="loadingIndicator">
<ngx-datatable-column [width]="30"
[sortable]="false"
[canAutoResize]="false"
[draggable]="false"
[resizeable]="false"
[headerCheckboxable]="true"
[checkboxable]="true">
</ngx-datatable-column>
<ngx-datatable-column name="Name" [canAutoResize]="true" [flexGrow]="1"></ngx-datatable-column>
<ngx-datatable-column name="Description" [flexGrow]="2">
<ng-template let-value="value" ngx-datatable-cell-template>
<div class="text-truncate">{{ value }}</div>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="Status" prop="state" [width]="70" [canAutoResize]="false">
<ng-template let-value="value" ngx-datatable-cell-template>
<span class="badge" [ngClass]="value === 'active' ? 'bg-success' : 'bg-warning text-dark'">{{ value }}</span>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="OS" [canAutoResize]="false" [width]="100">
<ng-template let-value="value" ngx-datatable-cell-template>
<div class="os" [class.smartos]="value === 'smartos'" [class.bsd]="value === 'bsd'"
[class.windows]="value === 'windows'" [class.linux]="value === 'linux'">
<span>{{ value }}</span>
</div>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="Type" [width]="120" [canAutoResize]="false"></ngx-datatable-column>
<ngx-datatable-column name="Publish date" prop="published_at" [width]="100" [canAutoResize]="false">
<ng-template let-value="value" ngx-datatable-cell-template>
{{ value | date | timeago }}
</ng-template>
</ngx-datatable-column>
<!--<ngx-datatable-column name="Tags">
<ng-template let-value="value" ngx-datatable-cell-template>
<ng-container *ngFor="let tag of value | keyvalue">
<span class="badge badge-dark mr-1">{{ tag.key }} <span class="text-primary">{{ tag.value }}</span></span>
</ng-container>
</ng-template>
</ngx-datatable-column>-->
<ngx-datatable-column [flexGrow]="1" cellClass="text-right" headerClass="text-right" [sortable]="false">
<ng-template let-value="value" ngx-datatable-cell-template>
<button class="btn btn-sm btn-primary m-1">Instances</button>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DockerImagesComponent } from './docker-images.component';
describe('DockerImagesComponent', () => {
let component: DockerImagesComponent;
let fixture: ComponentFixture<DockerImagesComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DockerImagesComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DockerImagesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,28 @@
import { Component, OnInit } from '@angular/core';
import { ColumnMode, SelectionType } from '@swimlane/ngx-datatable';
import { CatalogService } from '../helpers/catalog.service';
@Component({
selector: 'app-docker-images',
templateUrl: './docker-images.component.html',
styleUrls: ['./docker-images.component.scss']
})
export class DockerImagesComponent implements OnInit
{
rows: any[] = [];
loadingIndicator = true;
selectionType = SelectionType;
columnMode = ColumnMode;
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly catalogService: CatalogService)
{
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
}
}

View File

@ -0,0 +1 @@
<p>docker-registry-editor works!</p>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DockerRegistryEditorComponent } from './docker-registry-editor.component';
describe('DockerRegistryEditorComponent', () => {
let component: DockerRegistryEditorComponent;
let fixture: ComponentFixture<DockerRegistryEditorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DockerRegistryEditorComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DockerRegistryEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-docker-registry-editor',
templateUrl: './docker-registry-editor.component.html',
styleUrls: ['./docker-registry-editor.component.scss']
})
export class DockerRegistryEditorComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1 @@
<p>docker-registry works!</p>

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DockerRegistryComponent } from './docker-registry.component';
describe('DockerRegistryComponent', () => {
let component: DockerRegistryComponent;
let fixture: ComponentFixture<DockerRegistryComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DockerRegistryComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DockerRegistryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-docker-registry',
templateUrl: './docker-registry.component.html',
styleUrls: ['./docker-registry.component.scss']
})
export class DockerRegistryComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { CatalogService } from './catalog.service';
describe('CatalogService', () => {
let service: CatalogService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CatalogService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,165 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Cacheable } from 'ts-cacheable';
import { delay, filter, map, repeatWhen, take, tap } from 'rxjs/operators';
import { CatalogPackage } from '../models/package';
import { CatalogImage } from '../models/image';
const cacheBuster$ = new Subject<void>();
const imagesCacheBuster$ = new Subject<void>();
@Injectable({
providedIn: 'root'
})
export class CatalogService
{
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly httpClient: HttpClient) { }
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: cacheBuster$
})
getDataCenters(): Observable<any[]>
{
return this.httpClient.get<any[]>(`/api/my/datacenters`);
}
// ----------------------------------------------------------------------------------------------------------------
getServices(): Observable<any>
{
return this.httpClient.get<any>(`/api/my/services`);
}
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: cacheBuster$
})
getPackages(): Observable<CatalogPackage[]>
{
return this.httpClient.get<CatalogPackage[]>(`/api/my/packages`);
}
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: cacheBuster$
})
getPackage(packageId: string): Observable<CatalogPackage>
{
return this.httpClient.get<CatalogPackage>(`/api/my/packages/${packageId}`);
}
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: imagesCacheBuster$
})
getImages(allStates = false): Observable<CatalogImage[]>
{
return this.httpClient.get<CatalogImage[]>(`/api/my/images?${allStates ? 'state=all' : ''}`);
}
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: imagesCacheBuster$
})
getCustomImages(ownerId: string): Observable<CatalogImage[]>
{
return this.httpClient.get<CatalogImage[]>(`/api/my/images?$state=all&owner=${ownerId}`);
}
// ----------------------------------------------------------------------------------------------------------------
@Cacheable({
cacheBusterObserver: imagesCacheBuster$
})
getImage(id: string): Observable<CatalogImage>
{
return this.httpClient.get<CatalogImage>(`/api/my/images/${id}`);
}
// ----------------------------------------------------------------------------------------------------------------
getImageUntilExpectedState(image: CatalogImage, expectedStates: string[], maxRetries = 10): Observable<CatalogImage>
{
// Keep polling the image until it reaches the expected state
return this.httpClient.get<CatalogImage>(`/api/my/images/${image.id}`)
.pipe(
tap(x => image.state = x.state),
repeatWhen(x =>
{
let retries = 0;
return x.pipe(delay(3000), map(y =>
{
if (retries++ === maxRetries)
throw { error: `Failed to retrieve the current status for image "${image.name}"` };
return y;
}));
}),
filter(x => expectedStates.includes(x.state)),
take(1) // needed to stop the repeatWhen loop
);
}
// ----------------------------------------------------------------------------------------------------------------
createImage(instanceId: string, name: string, version: string,
description?: string, homepage?: string, eula?: string, acl?: string, tags?: string): Observable<CatalogImage>
{
return this.httpClient.post<any>(`/api/my/images`,
{
machine: instanceId,
name,
version,
description,
homepage,
eula,
acl,
tags
})
.pipe(tap(() => imagesCacheBuster$.next()));
}
// ----------------------------------------------------------------------------------------------------------------
importImage(sourceDataCenterId: string, imageId: string): Observable<CatalogImage>
{
// Copy the image with id from the source datacenter into this datacenter
return this.httpClient.post<CatalogImage>(`/api/my/images?action=import-from-datacenter`,
{
datacenter: sourceDataCenterId,
image: imageId
})
.pipe(tap(() => imagesCacheBuster$.next()));
}
// ----------------------------------------------------------------------------------------------------------------
editImage(imageId: string, name: string, version: string, description?: string, homepage?: string, eula?: string, acl?: string, tags?: string): Observable<any>
{
return this.httpClient.post<any>(`/api/my/images/${imageId}?action=update`,
{
name,
version,
description,
homepage,
eula,
acl,
tags
})
.pipe(tap(() => imagesCacheBuster$.next()));
}
// ----------------------------------------------------------------------------------------------------------------
cloneImage(imageId: string): Observable<any>
{
// https://apidocs.joyent.com/cloudapi/#CloneImage
return this.httpClient.post<any>(`/api/my/images/${imageId}?action=clone`, {})
.pipe(tap(() => imagesCacheBuster$.next()));
}
// ----------------------------------------------------------------------------------------------------------------
deleteImage(id: string): Observable<any>
{
// Note: Caller must be the owner of the image
return this.httpClient.delete(`/api/my/images/${id}`)
.pipe(tap(() => imagesCacheBuster$.next()));
}
}

View File

@ -0,0 +1,20 @@
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>OS</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let image of images">
<td>{{ image.name }}</td>
<td>{{ image.description }}</td>
<td>{{ image.os }}</td>
<td>
<button class="btn btn-sm btn-secondary" (click)="select.emit(image)">select</button>
</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImagesComponent } from './images.component';
describe('ImagesComponent', () => {
let component: ImagesComponent;
let fixture: ComponentFixture<ImagesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ImagesComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ImagesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,37 @@
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { ColumnMode, SelectionType } from '@swimlane/ngx-datatable';
import { CatalogService } from '../helpers/catalog.service';
@Component({
selector: 'app-images',
templateUrl: './images.component.html',
styleUrls: ['./images.component.scss']
})
export class ImagesComponent implements OnInit
{
@Output()
select = new EventEmitter();
images: any[];
loadingIndicator = true;
selectionType = SelectionType;
columnMode = ColumnMode;
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly catalogService: CatalogService)
{
catalogService.getImages().subscribe(x =>
{
this.images = x;
this.loadingIndicator = false;
});
catalogService.getDataCenters().subscribe(console.log);
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
}
}

View File

@ -0,0 +1,17 @@
export class CatalogImage
{
id: string;
name: string;
version: string;
description: string;
owner: string;
public: boolean;
published_at: Date;
state: string;
type: string;
os: string;
tags: { key: string; value: string };
requirements: any;
homepage: string;
image_size: number;
}

View File

@ -0,0 +1,20 @@
export class CatalogPackage
{
id: string;
name: string;
brand: string;
memory: number;
memorySize: string;
memorySizeLabel: string;
disk: number;
diskSize: string;
diskSizeLabel: string;
swap: number;
lwps: number;
vcpus: number;
version: string;
group: string;
description: string;
disks: any[];
flexible_disk: boolean;
}

View File

@ -0,0 +1,56 @@
<div class="text-center mt-3" *ngIf="loadingIndicator">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<ng-container *ngIf="!loadingIndicator">
<div class="btn-group w-100" btnRadioGroup>
<label [btnRadio]="group" class="btn" [class.active]="group === selectedPackageGroup" *ngFor="let group of packageGroups"
(click)="setPackageGroup($event, group)">
{{ group }}
</label>
</div>
<div class="list-group list-group-flush flex-grow-1" *ngIf="packages">
<ng-container *ngFor="let pkg of packages[selectedPackageGroup]">
<a *ngIf="pkg.visible" class="list-group-item list-group-item-action d-flex align-items-center justify-content-between">
<div class="form-check">
<input class="form-check-input" type="radio" id="pkg-{{ pkg.id }}" name="pkg" [value]="pkg" [(ngModel)]="selectedPackage">
<label class="form-check-label d-flex justify-content-between align-items-center pb-2" for="pkg-{{ pkg.id }}">
<span class="d-block flex-grow-1">
<span class="d-block">
<span class="h3 text-uppercase">
{{ pkg.name }}
<!--<small *ngIf="pkg.brand">{{ pkg.brand }}</small>-->
</span>
<small class="text-faded pb-1 d-block">v<b>{{ pkg.version }}</b></small>
<small class="mb-0 pe-3 text-faded" *ngIf="pkg.description">{{ pkg.description }}</small>
</span>
</span>
<b class="d-sm-flex flex-nowrap d-none justify-content-between">
<span class="package-specs">
<span class="title">CPU</span>
<span class="h5">{{ pkg.vcpus || 1 }}</span>
</span>
<span class="package-specs">
<span class="title">Memory</span>
<span class="h5">
{{ pkg.memorySize }}
<small>{{ pkg.memorySizeLabel }}</small>
</span>
</span>
<span class="package-specs">
<span class="title">Disk</span>
<span class="h5">
{{ pkg.diskSize }}
<small>{{ pkg.diskSizeLabel }}</small>
</span>
</span>
</b>
</label>
</div>
</a>
</ng-container>
</div>
</ng-container>

View File

@ -0,0 +1,151 @@
:host
{
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: auto;
}
.list-group:not(.select-list)
{
overflow: auto;
border-radius: 0;
border-top: 1px solid rgba(13, 195, 233, .5);
.list-group-item
{
border-radius: 0;
background: transparent;
color: #5a8cd8;
border-color: rgb(61, 94, 142, .25);
}
}
.btn-group
{
.btn
{
background: none;
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
border-bottom: 2px solid transparent;
transition: none;
color: #0dc3e9;
&.active
{
border-bottom-color: #0dc3e9;
box-shadow: 0 -1rem 1.5rem -1.5rem inset;
text-shadow: 0 0 .5rem;
}
&:focus:not(.active)
{
box-shadow: none;
}
}
}
.form-check
{
display: flex;
align-items: center;
padding: 0;
margin: 0 0 0 2.5rem;
cursor: pointer;
flex-grow: 1;
.form-check-input
{
margin-right: .5rem;
float: none;
width: 1.4em;
max-width: 1rem;
margin-bottom: .25rem;
cursor: inherit;
background-color: #0dc3e9;
border-color: #0dc3e9;
box-shadow: 0 0 0 1px rgba(12, 19, 33, .5) inset;
&:checked[type=radio]
{
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230c1321'/%3e%3c/svg%3e");
background-color: #ff9c07;
border-color: #ff9c07;
&:not(:focus)
{
box-shadow: none;
}
}
&:focus
{
box-shadow: 0 0 0 0.25rem rgba(255, 156, 7, .25);
}
}
.h3
{
text-transform: uppercase;
display: block;
margin-bottom: 0;
line-height: 1;
font-size: 1.5rem;
color: #8881ff;
}
.form-check-label
{
cursor: inherit;
width: 100%;
padding: .75rem .25rem;
color: #5a8cd8;
font-family: "Mukta", sans-serif;
text-transform: none;
}
}
.form-check-input:checked + .form-check-label,
.form-check-input:checked + .form-check-label .package-specs,
.form-check-input:checked + .form-check-label .h3
{
color: #ff9c07;
}
.list-group-item-action .h5
{
font-size: 2.5rem;
float: left;
margin: .5rem 0 -.25rem 0;
small
{
font-size: .8rem;
margin-left: -.5rem;
}
}
.package-specs
{
display: inline-block;
padding: 2px 2px 2px 1rem;
font-size: .9rem;
width: 100px;
border-left: 1px solid rgba(61, 94, 142, 0.25);
position: relative;
margin: 0 .5rem;
flex-grow: 1;
flex-basis: 0;
white-space: nowrap;
.title
{
position: absolute;
top: 0;
left: 1rem;
opacity: .75;
}
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PackagesComponent } from './packages.component';
describe('PackagesComponent', () => {
let component: PackagesComponent;
let fixture: ComponentFixture<PackagesComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PackagesComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PackagesComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,195 @@
import { Component, OnInit, OnChanges, Input, Output, EventEmitter, SimpleChanges } from '@angular/core';
import { OnDestroy } from '@angular/core/core';
import { ReplaySubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { FileSizePipe } from '../../pipes/file-size.pipe';
import { CatalogService } from '../helpers/catalog.service';
import { CatalogImage } from '../../catalog/models/image';
@Component({
selector: 'app-packages',
templateUrl: './packages.component.html',
styleUrls: ['./packages.component.scss']
})
export class PackagesComponent implements OnInit, OnDestroy, OnChanges
{
@Input()
imageType: number;
@Input()
image: CatalogImage;
@Input()
package: string;
@Output()
select = new EventEmitter();
packageGroups: any[];
loadingIndicator: boolean;
selectedPackageGroup: string;
private packages: {};
private _selectedPackage: {};
private destroy$ = new Subject();
private onChanges$ = new ReplaySubject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly catalogService: CatalogService,
private readonly fileSizePipe: FileSizePipe)
{
this.getPackages();
}
// ----------------------------------------------------------------------------------------------------------------
setPackageGroup(event, packageGroup: string)
{
this.selectedPackageGroup = packageGroup;
if (!packageGroup) return;
switch (packageGroup)
{
case 'cpu':
this.packages[packageGroup].sort((a, b) => (a.vcpus || 1) - (b.vcpus || 1));
break;
case 'disk':
this.packages[packageGroup].sort((a, b) => a.disk - b.disk);
break;
case 'memory optimized':
this.packages[packageGroup].sort((a, b) => a.memory - b.memory);
break;
default:
this.packages[packageGroup].sort((a, b) => ((a.vcpus || 1) - (b.vcpus || 1)) || (a.memory - b.memory) || (a.disk - b.disk));
break;
}
}
// ----------------------------------------------------------------------------------------------------------------
set selectedPackage(value)
{
this._selectedPackage = value;
this.select.next(value);
}
get selectedPackage()
{
return this._selectedPackage;
}
// ----------------------------------------------------------------------------------------------------------------
private getPackages()
{
this.loadingIndicator = true;
this.catalogService.getPackages()
.subscribe(response =>
{
if (this.packages)
return;
this.packages = response.reduce((groups, pkg) =>
{
let size = this.fileSizePipe.transform(pkg.memory * 1024 * 1024);
[pkg.memorySize, pkg.memorySizeLabel] = size.split(' ');
size = this.fileSizePipe.transform(pkg.disk * 1024 * 1024);
[pkg.diskSize, pkg.diskSizeLabel] = size.split(' ');
const groupName = pkg.group.toLowerCase() || 'standard';
const group = (groups[groupName] || []);
group.push(pkg);
groups[groupName] = group;
return groups;
}, {});
this.setPackageGroups();
});
}
// ----------------------------------------------------------------------------------------------------------------
private setPackageGroups()
{
if (!this.packages || !this.image || !this.imageType)
return;
// Setup the operating systems array-like object, sorted alphabetically
this.packageGroups = Object.keys(this.packages)
.filter(x =>
{
this.packages[x].forEach(p =>
{
if (p.name === this.package)
this._selectedPackage = p;
if (!p.brand || !this.image)
{
p.visible = true;
return;
}
if (this.image.requirements.brand)
p.visible = this.image.requirements.brand === p.brand;
if (this.image.type === 'zone-dataset')
p.visible = ['joyent', 'joyent-minimal'].includes(p.brand);
if (this.image.type === 'lx-dataset')
p.visible = p.brand === 'lx';
if (this.image.type === 'zvol')
p.visible = ['bhyve', 'kvm'].includes(p.brand);
});
switch (this.imageType | 0)
{
case 1:
return this.packages[x].length && (!x || ['cpu', 'disk', 'memory optimized', 'standard', 'triton'].includes(x));
case 2:
return this.packages[x].length && (!x || ['standard', 'triton'].includes(x));
default:
return false;
}
})
.sort((a, b) => a.localeCompare(b));
// Set the pre-selected package group
this.selectedPackageGroup = this.packageGroups[0];
if (this.selectedPackage)
this.select.emit(this.selectedPackage);
this.loadingIndicator = false;
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
this.onChanges$.pipe(takeUntil(this.destroy$))
.subscribe((changes: SimpleChanges) =>
{
if (changes.image?.currentValue && changes.imageType?.currentValue)
this.setPackageGroups();
});
}
// ----------------------------------------------------------------------------------------------------------------
ngOnChanges(changes: SimpleChanges): void
{
// Since we can't control if ngOnChanges is executed before ngOnInit, we need this trick
this.onChanges$.next(changes);
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy(): void
{
this.destroy$.next();
}
}

View File

@ -0,0 +1,21 @@
<fieldset>
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="declineAction()">
<span aria-hidden="true">&times;</span>
</button>
<div class="content">
<h4 class="mb-3">{{ title }}</h4>
<p class="my-2">{{ prompt }}</p>
<div class="d-flex justify-content-end align-items-center mt-5">
<button class="btn" [ngClass]="confirmByDefault ? 'btn-link text-info me-3' : 'btn-info order-1'" (click)="declineAction()">
{{ declineButtonText }}
</button>
<button class="btn" [ngClass]="!confirmByDefault ? 'btn-link text-info me-3' : 'btn-info'" (click)="confirmAction()">
{{ confirmButtonText }}
</button>
</div>
</div>
</fieldset>

View File

@ -0,0 +1,5 @@
p
{
color: #ff9c07;
font-size: 1.15rem;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmationDialogComponent } from './confirmation-dialog.component';
describe('ConfirmationDialogComponent', () => {
let component: ConfirmationDialogComponent;
let fixture: ComponentFixture<ConfirmationDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ConfirmationDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,68 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-confirmation-dialog',
templateUrl: './confirmation-dialog.component.html',
styleUrls: ['./confirmation-dialog.component.scss']
})
export class ConfirmationDialogComponent implements OnInit, OnDestroy
{
@Input()
title = 'Confirmation';
@Input()
prompt: string;
@Input()
confirmButtonText = 'Yes';
@Input()
declineButtonText = 'No';
@Input()
confirmByDefault = true;
confirm = new Subject();
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly modalRef: BsModalRef,
private readonly router: Router)
{ // When the user navigates away from this route, hide the modal
router.events
.pipe(
takeUntil(this.destroy$),
filter(e => e instanceof NavigationStart)
)
.subscribe(() => this.modalRef.hide());
}
// ----------------------------------------------------------------------------------------------------------------
declineAction()
{
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
confirmAction()
{
this.confirm.next();
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit()
{
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.destroy$.next();
}
}

View File

@ -0,0 +1,22 @@
<button class="btn btn-link text-info" (click)="showEditor()" [collapse]="editorVisible" [disabled]="disabled">{{ buttonTitle }}</button>
<div [collapse]="!editorVisible">
<div class="row gx-1 my-1 align-items-center" [formGroup]="editorForm">
<div class="col align-self-stretch">
<input class="form-control h-100" name="key" type="text" [placeholder]="keyLabel" formControlName="key"
[appAutofocus]="editorVisible" [appAlphaOnly]="keyAllowedCharacters" />
</div>
<div class="col-sm-6" *ngIf="showValue">
<textarea class="form-control" name="value" formControlName="value" [class.single-line]="singleLine"
autosize rows="1" [maxRows]="singleLine ? 1 : 3" [placeholder]="valueLabel" [appAlphaOnly]="valueAllowedCharacters"></textarea>
</div>
<div class="col-sm-2 d-flex flex-nowrap justify-content-between align-items-start">
<button class="btn px-1 text-success" (click)="saveChanges()" [disabled]="editorForm.invalid">
<fa-icon [fixedWidth]="true" icon="check"></fa-icon>
</button>
<button class="btn px-1 text-danger" (click)="cancelChanges()">
<fa-icon [fixedWidth]="true" icon="times"></fa-icon>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,20 @@
.single-line
{
resize: none;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden !important;
}
.btn
{
padding-top: .4em;
}
.form-control
{
background: #0c1321;
border-color: #00e7ff;
border-radius: 3rem;
color: #ff9c07;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { InlineEditorComponent } from './inline-editor.component';
describe('InlineEditorComponent', () => {
let component: InlineEditorComponent;
let fixture: ComponentFixture<InlineEditorComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ InlineEditorComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(InlineEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,176 @@
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ElementRef, HostListener } from '@angular/core';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
@Component({
selector: 'app-inline-editor',
templateUrl: './inline-editor.component.html',
styleUrls: ['./inline-editor.component.scss']
})
export class InlineEditorComponent implements OnInit, OnDestroy
{
@Input()
buttonTitle: string;
@Input()
singleLine: boolean;
@Input()
key: string;
@Input()
keyLabel = 'Key';
@Input()
keyAllowedCharacters: string;
@Input()
keyPattern: string;
@Input()
value: string;
@Input()
valueLabel = 'Value';
@Input()
valueAllowedCharacters: string;
@Input()
valuePattern: string;
@Input()
showValue = true;
@Input()
disabled: boolean;
@Output()
saved = new EventEmitter();
editorVisible: boolean;
editorForm: FormGroup;
// --------------------------------------------------------------------------------------------------
constructor(private readonly elementRef: ElementRef,
private readonly fb: FormBuilder) { }
// ----------------------------------------------------------------------------------------------------------------
private createForm()
{
this.editorForm = this.fb.group(
{
key: [this.key],
value: [this.value]
});
if (this.keyPattern)
this.editorForm.get('key').setValidators([Validators.required, Validators.pattern(this.keyPattern)]);
else
this.editorForm.get('key').setValidators([Validators.required]);
if (this.valuePattern)
this.editorForm.get('value').setValidators([Validators.required, Validators.pattern(this.valuePattern)]);
else if (this.showValue)
this.editorForm.get('value').setValidators([Validators.required]);
}
// --------------------------------------------------------------------------------------------------
showEditor()
{
if (this.disabled) return;
this.editorVisible = true;
addEventListener('click', this.onDocumentClick.bind(this));
}
// --------------------------------------------------------------------------------------------------
saveChanges()
{
event.preventDefault();
event.stopPropagation();
this.editorVisible = false;
this.removeEventListeners();
if (this.showValue)
this.saved.emit({
key: this.editorForm.get('key').value,
value: this.editorForm.get('value').value
});
else
this.saved.emit(this.editorForm.get('key').value);
this.editorForm.get('key').setValue(null);
this.editorForm.get('value').setValue(null);
}
// --------------------------------------------------------------------------------------------------
cancelChanges()
{
this.editorVisible = false;
this.removeEventListeners();
this.editorForm.get('key').setValue(null);
this.editorForm.get('value').setValue(null);
}
// --------------------------------------------------------------------------------------------------
@HostListener('document:keydown.enter', ['$event'])
returnPressed(event)
{
if (event.currentTarget === this.elementRef.nativeElement && this.singleLine)
{
event.preventDefault();
event.stopPropagation();
}
}
// --------------------------------------------------------------------------------------------------
@HostListener('document:keydown.escape', ['$event'])
escapePressed(event)
{
this.cancelChanges();
}
// --------------------------------------------------------------------------------------------------
@HostListener('input', ['$event'])
textEntered(event)
{
if (event.currentTarget === this.elementRef.nativeElement && this.singleLine && this.value)
this.editorForm.get('value').setValue(this.editorForm.get('value').value.replace(/\n/g, ''));
}
// --------------------------------------------------------------------------------------------------
protected onDocumentClick(event: MouseEvent)
{
if (!this.elementRef.nativeElement.contains(event.target))
this.cancelChanges();
}
// --------------------------------------------------------------------------------------------------
private removeEventListeners()
{
removeEventListener('click', this.onDocumentClick);
removeEventListener('input', this.textEntered);
removeEventListener('document:keydown.enter', this.returnPressed);
removeEventListener('document:keydown.escape', this.escapePressed);
}
// --------------------------------------------------------------------------------------------------
ngOnInit(): void
{
if (!this.buttonTitle)
throw 'Specify a button title for the inline editor';
this.createForm();
}
// --------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.removeEventListeners();
}
}

View File

@ -0,0 +1,68 @@
<nav class="navbar navbar-expand-sm navbar-dark">
<ul class="navbar-nav flex-column w-100" *ngIf="isAuthenticated">
<li class="nav-item">
<a class="nav-link" [routerLink]="['./']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="home"></fa-icon>
{{ 'navbar.menu.dashboard' | translate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./networking/networks']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="network-wired"></fa-icon>
{{ 'navbar.menu.networks' | translate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./networking/firewall-rules']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="fire-alt"></fa-icon>
{{ 'navbar.menu.firewallRules' | translate }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./volumes']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="database"></fa-icon>
{{ 'navbar.menu.volumes' | translate }}
</a>
</li>
<li class="dropdown-divider"></li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./security']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="shield-alt"></fa-icon>
{{ 'navbar.menu.security' | translate }}
</a>
</li>
<!--<li class="dropdown-divider"></li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./file-manager']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="folder"></fa-icon>
{{ 'navbar.menu.fileManager' | translate }}
</a>
</li>-->
<li class="dropdown-divider"></li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./catalog/custom-images']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="layer-group"></fa-icon>
{{ 'navbar.menu.customImages' | translate }}
</a>
</li>
<!--<li class="nav-item">
<a class="nav-link" [routerLink]="['./catalog/docker-images']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" [icon]="['fab', 'docker']"></fa-icon>
{{ 'navbar.menu.dockerImages' | translate }}
</a>
</li>-->
<!--<li class="nav-item">
<a class="nav-link" [routerLink]="['./catalog/docker-registry']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" [icon]="['fab', 'docker']"></fa-icon>
{{ 'navbar.menu.dockerRegistry' | translate }}
</a>
</li>-->
<!--<li class="dropdown-divider"></li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['./account']" [routerLinkActive]="['active']" [routerLinkActiveOptions]="{ exact: true }">
<fa-icon [fixedWidth]="true" icon="user-cog"></fa-icon>
{{ 'navbar.menu.account' | translate }}
</a>
</li>-->
</ul>
</nav>

View File

@ -0,0 +1,17 @@
.dropdown-divider
{
border-color: #222740;
}
.navbar-dark .navbar-nav .nav-link
{
color: #bfb7b1;
}
.navbar-dark .navbar-nav .active > .nav-link,
.navbar-dark .navbar-nav .nav-link.active,
.navbar-dark .navbar-nav .nav-link.show,
.navbar-dark .navbar-nav .show > .nav-link
{
color: #f0ad4e;
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NavMenuComponent } from './nav-menu.component';
describe('NavMenuComponent', () => {
let component: NavMenuComponent;
let fixture: ComponentFixture<NavMenuComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ NavMenuComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NavMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,33 @@
import { Component, OnInit, Output, EventEmitter } from '@angular/core';
import { Router, NavigationStart } from '@angular/router';
import { filter } from 'rxjs/operators';
import { TokenService } from '../../helpers/token.service';
@Component({
selector: 'app-nav-menu',
templateUrl: './nav-menu.component.html',
styleUrls: ['./nav-menu.component.scss']
})
export class NavMenuComponent implements OnInit
{
@Output()
navigate = new EventEmitter();
isAuthenticated = false;
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly router: Router,
private readonly tokenService: TokenService)
{
router.events
.pipe(filter(e => e instanceof NavigationStart))
.subscribe(e => this.navigate.emit(e));
tokenService.accessTokenUpdated$.subscribe(x => this.isAuthenticated = !!x);
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit(): void
{
}
}

View File

@ -0,0 +1,19 @@
<form novalidate>
<fieldset [formGroup]="editorForm" [disabled]="working" *ngIf="editorForm">
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
<div class="content">
<h4 class="mb-3">{{ title }}</h4>
<p class="my-2">{{ prompt }}</p>
<input type="text" class="form-control" id="value" formControlName="value" [placeholder]="placeholder" [appAutofocus]="true" [appAutofocusDelay]="600">
<div class="d-flex justify-content-end align-items-center mt-5">
<button class="btn btn-info" (click)="saveChanges()" [disabled]="editorForm.invalid">{{ saveButtonText }}</button>
</div>
</div>
</fieldset>
</form>

View File

@ -0,0 +1,5 @@
p
{
color: #ff9c07;
font-size: 1.15rem;
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PromptDialogComponent } from './prompt-dialog.component';
describe('PromptDialogComponent', () => {
let component: PromptDialogComponent;
let fixture: ComponentFixture<PromptDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PromptDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PromptDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,87 @@
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { BsModalRef } from 'ngx-bootstrap/modal';
import { FormGroup, FormBuilder, Validators, AbstractControl, FormArray } from '@angular/forms';
import { NavigationStart, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-prompt-dialog',
templateUrl: './prompt-dialog.component.html',
styleUrls: ['./prompt-dialog.component.scss']
})
export class PromptDialogComponent implements OnInit, OnDestroy
{
@Input()
title: string;
@Input()
prompt: string;
@Input()
saveButtonText = 'Save changes';
@Input()
value: string;
@Input()
placeholder: string;
@Input()
required: boolean;
save = new Subject<string>();
editorForm: FormGroup;
private destroy$ = new Subject();
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly modalRef: BsModalRef,
private readonly router: Router,
private readonly fb: FormBuilder)
{ // When the user navigates away from this route, hide the modal
router.events
.pipe(
takeUntil(this.destroy$),
filter(e => e instanceof NavigationStart)
)
.subscribe(() => this.modalRef.hide());
}
// ----------------------------------------------------------------------------------------------------------------
private createForm()
{
this.editorForm = this.fb.group(
{
value: [this.value]
});
if (this.required)
this.editorForm.get('value').setValidators(Validators.required);
}
// ----------------------------------------------------------------------------------------------------------------
close()
{
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
saveChanges()
{
this.save.next(this.editorForm.get('value').value);
this.modalRef.hide();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnInit()
{
this.createForm();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
this.destroy$.next();
}
}

View File

@ -0,0 +1,8 @@
import { AlphaOnlyDirective } from './alpha-only.directive';
describe('AlphaOnlyDirective', () => {
it('should create an instance', () => {
const directive = new AlphaOnlyDirective();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,53 @@
import { Directive, ElementRef, HostListener, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
@Directive({
selector: '[appAlphaOnly]'
})
export class AlphaOnlyDirective implements OnInit, OnChanges
{
@Input()
appAlphaOnly = '^[A-Za-z0-9]+$';
private regex;
//private negateRegex;
// --------------------------------------------------------------------------------------------------
constructor(private readonly el: ElementRef) { }
// --------------------------------------------------------------------------------------------------
@HostListener('keypress', ['$event'])
onKeyPress(event)
{
return this.regex ? this.regex.test(event.key) : true;
}
// --------------------------------------------------------------------------------------------------
@HostListener('paste', ['$event'])
onPaste(event)
{
if (!this.regex) return;
event.preventDefault();
//const value = event.clipboardData.getData('text/plain').replace(this.negateRegex, '');
const value = event.clipboardData.getData('text/plain').trim();
if (value.match(this.regex))
document.execCommand('insertHTML', false, value);
}
// --------------------------------------------------------------------------------------------------
ngOnInit()
{
if (!this.appAlphaOnly) return;
this.regex = new RegExp(this.appAlphaOnly);
//this.regex = new RegExp(`^[${this.appAlphaOnly}]+$`);
//this.negateRegex = new RegExp(`[^${this.appAlphaOnly}]`, 'g');
}
// --------------------------------------------------------------------------------------------------
ngOnChanges(changes: SimpleChanges): void
{
if (changes.appAlphaOnly.currentValue)
this.regex = new RegExp(this.appAlphaOnly);
}
}

View File

@ -0,0 +1,8 @@
import { AutofocusDirective } from './autofocus.directive';
describe('AutofocusDirective', () => {
it('should create an instance', () => {
const directive = new AutofocusDirective();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,64 @@
import { Directive, Input, ElementRef, AfterViewInit, OnChanges, SimpleChanges } from '@angular/core';
@Directive({
selector: '[appAutofocus]'
})
export class AutofocusDirective implements AfterViewInit, OnChanges
{
@Input()
appAutofocus = false;
@Input()
appAutofocusDelay: number;
@Input()
appFocusAnyElement: boolean;
private focusElement: any;
// --------------------------------------------------------------------------------------------------
constructor(private readonly element: ElementRef) { }
// --------------------------------------------------------------------------------------------------
ngAfterViewInit(): void
{
if (this.element.nativeElement.nodeName === 'INPUT' ||
this.element.nativeElement.nodeName === 'TEXTAREA' ||
this.element.nativeElement.nodeName === 'SELECT')
this.focusElement = this.element.nativeElement;
else
this.focusElement = this.appFocusAnyElement
? this.element.nativeElement
: this.element.nativeElement.querySelector('select, textarea, input');
this.setFocus();
}
// --------------------------------------------------------------------------------------------------
ngOnChanges(changes: SimpleChanges): void
{
this.setFocus();
}
// --------------------------------------------------------------------------------------------------
private setFocus()
{
if (!this.appAutofocus) return;
setTimeout(() =>
{
if (!this.focusElement) return;
this.focusElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
try
{
this.focusElement.select();
}
catch (e)
{
this.focusElement.focus();
}
}, (this.appAutofocusDelay | 0));
}
}

View File

@ -0,0 +1,8 @@
import { LazyLoadDirective } from './lazy-load.directive';
describe('LazyLoadDirective', () => {
it('should create an instance', () => {
const directive = new LazyLoadDirective();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,89 @@
import { Directive, AfterViewInit, OnDestroy, ElementRef, Output, EventEmitter, Input } from '@angular/core';
@Directive({
selector: '[lazyLoad]'
})
export class LazyLoadDirective implements AfterViewInit, OnDestroy
{
@Input()
container: any;
@Input()
lazyLoadDelay: number;
@Output()
canLoad = new EventEmitter();
@Output()
load = new EventEmitter();
@Output()
unload = new EventEmitter();
private observer: any;
private delay: any;
private options: {};
// ----------------------------------------------------------------------------------------------------------------
constructor(private readonly el: ElementRef) { }
// ----------------------------------------------------------------------------------------------------------------
private canLazyLoad = () => window && 'IntersectionObserver' in window;
// ----------------------------------------------------------------------------------------------------------------
private loadOnIntersection()
{
this.observer = new IntersectionObserver(entries =>
{
entries.forEach(({ isIntersecting }) =>
{
if (isIntersecting)
{
this.canLoad.emit();
this.delay = setTimeout(() =>
{
this.load.emit();
}, this.lazyLoadDelay);
}
else
{
clearTimeout(this.delay);
this.unload.emit();
}
});
});
this.observer.observe(this.el.nativeElement, this.options);
}
// ----------------------------------------------------------------------------------------------------------------
private loadWithDelay()
{
this.canLoad.emit();
this.delay = setTimeout(() =>
{
this.load.emit();
}, this.lazyLoadDelay);
}
// ----------------------------------------------------------------------------------------------------------------
ngAfterViewInit()
{
this.options = {
threshold: 1.0,
rootMargin: '0px',
root: this.container
};
this.canLazyLoad() ? this.loadOnIntersection() : this.loadWithDelay();
}
// ----------------------------------------------------------------------------------------------------------------
ngOnDestroy()
{
if (this.observer)
this.observer.unobserve(this.el.nativeElement);
}
}

View File

@ -0,0 +1 @@
<p>file-manager works!</p>

Some files were not shown because too many files have changed in this diff Show More