added app project files
This commit is contained in:
parent
991f5cabdc
commit
20ee57102e
13
app/.editorconfig
Normal file
13
app/.editorconfig
Normal 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
42
app/.gitignore
vendored
Normal 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
1
app/README.md
Normal 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
196
app/angular.json
Normal 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
9
app/browserslist
Normal 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
|
28
app/e2e/protractor.conf.js
Normal file
28
app/e2e/protractor.conf.js
Normal 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 } }));
|
||||
}
|
||||
};
|
14
app/e2e/src/app.e2e-spec.ts
Normal file
14
app/e2e/src/app.e2e-spec.ts
Normal 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
11
app/e2e/src/app.po.ts
Normal 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
13
app/e2e/tsconfig.e2e.json
Normal 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
29
app/ngsw-config.json
Normal 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
16038
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
app/package.json
Normal file
79
app/package.json
Normal 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
8
app/proxy.conf.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
'/api': {
|
||||
target: 'https://localhost:8443',
|
||||
secure: false,
|
||||
pathRewrite: {'^/api': ''},
|
||||
logLevel: 'debug'
|
||||
}
|
||||
}
|
@ -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">×</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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
101
app/src/app/account/account-editor/account-editor.component.ts
Normal file
101
app/src/app/account/account-editor/account-editor.component.ts
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
55
app/src/app/account/account.component.html
Normal file
55
app/src/app/account/account.component.html
Normal 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>
|
42
app/src/app/account/account.component.scss
Normal file
42
app/src/app/account/account.component.scss
Normal 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;
|
||||
}
|
||||
}
|
25
app/src/app/account/account.component.spec.ts
Normal file
25
app/src/app/account/account.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
90
app/src/app/account/account.component.ts
Normal file
90
app/src/app/account/account.component.ts
Normal 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();
|
||||
}
|
||||
}
|
48
app/src/app/account/account.module.ts
Normal file
48
app/src/app/account/account.module.ts
Normal 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));
|
||||
}
|
||||
}
|
16
app/src/app/account/helpers/account.service.spec.ts
Normal file
16
app/src/app/account/helpers/account.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
50
app/src/app/account/helpers/account.service.ts
Normal file
50
app/src/app/account/helpers/account.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
6
app/src/app/account/models/user-info.ts
Normal file
6
app/src/app/account/models/user-info.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { User } from '../../security/models/user';
|
||||
|
||||
export class UserInfo extends User
|
||||
{
|
||||
triton_cns_enabled: boolean;
|
||||
}
|
6
app/src/app/account/models/user-key.ts
Normal file
6
app/src/app/account/models/user-key.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export class UserKey
|
||||
{
|
||||
fingerprint: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
@ -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">×</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>
|
@ -0,0 +1,18 @@
|
||||
p
|
||||
{
|
||||
color: #ff9c07;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.form-control
|
||||
{
|
||||
+ .form-control
|
||||
{
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
textarea
|
||||
{
|
||||
border-radius: 2rem;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
88
app/src/app/app-routing.module.ts
Normal file
88
app/src/app/app-routing.module.ts
Normal 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 { }
|
57
app/src/app/app.component.html
Normal file
57
app/src/app/app.component.html
Normal 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>
|
200
app/src/app/app.component.scss
Normal file
200
app/src/app/app.component.scss
Normal 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;
|
||||
}
|
82
app/src/app/app.component.ts
Normal file
82
app/src/app/app.component.ts
Normal 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
69
app/src/app/app.module.ts
Normal 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');
|
||||
}
|
||||
}
|
11
app/src/app/app.server.module.ts
Normal file
11
app/src/app/app.server.module.ts
Normal 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 { }
|
1
app/src/app/catalog/catalog.component.html
Normal file
1
app/src/app/catalog/catalog.component.html
Normal file
@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
0
app/src/app/catalog/catalog.component.scss
Normal file
0
app/src/app/catalog/catalog.component.scss
Normal file
25
app/src/app/catalog/catalog.component.spec.ts
Normal file
25
app/src/app/catalog/catalog.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
21
app/src/app/catalog/catalog.component.ts
Normal file
21
app/src/app/catalog/catalog.component.ts
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
99
app/src/app/catalog/catalog.module.ts
Normal file
99
app/src/app/catalog/catalog.module.ts
Normal 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));
|
||||
}
|
||||
}
|
@ -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">×</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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
128
app/src/app/catalog/custom-images/custom-images.component.html
Normal file
128
app/src/app/catalog/custom-images/custom-images.component.html
Normal 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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
191
app/src/app/catalog/custom-images/custom-images.component.ts
Normal file
191
app/src/app/catalog/custom-images/custom-images.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1 @@
|
||||
<p>docker-image-editor works!</p>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
28
app/src/app/catalog/docker-images/docker-images.component.ts
Normal file
28
app/src/app/catalog/docker-images/docker-images.component.ts
Normal 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
|
||||
{
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1 @@
|
||||
<p>docker-registry-editor works!</p>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1 @@
|
||||
<p>docker-registry works!</p>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {
|
||||
}
|
||||
|
||||
}
|
16
app/src/app/catalog/helpers/catalog.service.spec.ts
Normal file
16
app/src/app/catalog/helpers/catalog.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
165
app/src/app/catalog/helpers/catalog.service.ts
Normal file
165
app/src/app/catalog/helpers/catalog.service.ts
Normal 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()));
|
||||
}
|
||||
}
|
20
app/src/app/catalog/images/images.component.html
Normal file
20
app/src/app/catalog/images/images.component.html
Normal 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>
|
0
app/src/app/catalog/images/images.component.scss
Normal file
0
app/src/app/catalog/images/images.component.scss
Normal file
25
app/src/app/catalog/images/images.component.spec.ts
Normal file
25
app/src/app/catalog/images/images.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
37
app/src/app/catalog/images/images.component.ts
Normal file
37
app/src/app/catalog/images/images.component.ts
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
17
app/src/app/catalog/models/image.ts
Normal file
17
app/src/app/catalog/models/image.ts
Normal 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;
|
||||
}
|
20
app/src/app/catalog/models/package.ts
Normal file
20
app/src/app/catalog/models/package.ts
Normal 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;
|
||||
}
|
56
app/src/app/catalog/packages/packages.component.html
Normal file
56
app/src/app/catalog/packages/packages.component.html
Normal 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>
|
151
app/src/app/catalog/packages/packages.component.scss
Normal file
151
app/src/app/catalog/packages/packages.component.scss
Normal 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;
|
||||
}
|
||||
}
|
25
app/src/app/catalog/packages/packages.component.spec.ts
Normal file
25
app/src/app/catalog/packages/packages.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
195
app/src/app/catalog/packages/packages.component.ts
Normal file
195
app/src/app/catalog/packages/packages.component.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<fieldset>
|
||||
<button type="button" class="close" [attr.aria-label]="'general.closeWithoutSaving' | translate" (click)="declineAction()">
|
||||
<span aria-hidden="true">×</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>
|
@ -0,0 +1,5 @@
|
||||
p
|
||||
{
|
||||
color: #ff9c07;
|
||||
font-size: 1.15rem;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
176
app/src/app/components/inline-editor/inline-editor.component.ts
Normal file
176
app/src/app/components/inline-editor/inline-editor.component.ts
Normal 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();
|
||||
}
|
||||
}
|
68
app/src/app/components/nav-menu/nav-menu.component.html
Normal file
68
app/src/app/components/nav-menu/nav-menu.component.html
Normal 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>
|
17
app/src/app/components/nav-menu/nav-menu.component.scss
Normal file
17
app/src/app/components/nav-menu/nav-menu.component.scss
Normal 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;
|
||||
}
|
25
app/src/app/components/nav-menu/nav-menu.component.spec.ts
Normal file
25
app/src/app/components/nav-menu/nav-menu.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
33
app/src/app/components/nav-menu/nav-menu.component.ts
Normal file
33
app/src/app/components/nav-menu/nav-menu.component.ts
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
@ -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">×</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>
|
@ -0,0 +1,5 @@
|
||||
p
|
||||
{
|
||||
color: #ff9c07;
|
||||
font-size: 1.15rem;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
8
app/src/app/directives/alpha-only.directive.spec.ts
Normal file
8
app/src/app/directives/alpha-only.directive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
53
app/src/app/directives/alpha-only.directive.ts
Normal file
53
app/src/app/directives/alpha-only.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
8
app/src/app/directives/autofocus.directive.spec.ts
Normal file
8
app/src/app/directives/autofocus.directive.spec.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { AutofocusDirective } from './autofocus.directive';
|
||||
|
||||
describe('AutofocusDirective', () => {
|
||||
it('should create an instance', () => {
|
||||
const directive = new AutofocusDirective();
|
||||
expect(directive).toBeTruthy();
|
||||
});
|
||||
});
|
64
app/src/app/directives/autofocus.directive.ts
Normal file
64
app/src/app/directives/autofocus.directive.ts
Normal 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));
|
||||
}
|
||||
}
|
8
app/src/app/directives/lazy-load.directive.spec.ts
Normal file
8
app/src/app/directives/lazy-load.directive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
89
app/src/app/directives/lazy-load.directive.ts
Normal file
89
app/src/app/directives/lazy-load.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
1
app/src/app/file-manager/file-manager.component.html
Normal file
1
app/src/app/file-manager/file-manager.component.html
Normal 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
Reference in New Issue
Block a user