Volumes Management UI (kubeflow/kubeflow#5684)

* vwa(back): first commit of volumes web app backend

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa(front): init code for volumes web app frontend

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa: {Make,Docker}files

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa: gitignore

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* vwa: README

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* review: Rename Jupyter to Volumes in README

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* review: Rephrase comment in default flavor

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>

* review: Remove snapshot reference from default flavor

Signed-off-by: Kimonas Sotirchos <kimwnasptd@arrikto.com>
This commit is contained in:
Kimonas Sotirchos 2021-03-18 10:07:16 +02:00 committed by GitHub
parent a1e52c2b9e
commit a63eff43ed
86 changed files with 15957 additions and 0 deletions

View File

@ -0,0 +1,3 @@
**/__pycache__/
**/static/*
py/

View File

@ -0,0 +1,50 @@
# --- Build the backend kubeflow-wheel ---
FROM python:3.8 AS backend-kubeflow-wheel
WORKDIR /src
COPY ./common/backend/ .
RUN python3 setup.py bdist_wheel
# --- Build the frontend kubeflow library ---
FROM node:12 as frontend-kubeflow-lib
WORKDIR /src
COPY ./common/frontend/kubeflow-common-lib/package*.json ./
RUN npm install
COPY ./common/frontend/kubeflow-common-lib/ .
RUN npm run build
# --- Build the frontend ---
FROM node:12-buster-slim as frontend
WORKDIR /src
COPY ./volumes/frontend/package*.json ./
RUN npm install
COPY --from=frontend-kubeflow-lib /src/dist/kubeflow/ ./node_modules/kubeflow/
COPY ./volumes/frontend/ .
RUN npm run build -- --output-path=./dist/default --configuration=production
RUN npm run build -- --output-path=./dist/rok --configuration=rok-prod
# Web App
FROM python:3.7-slim-buster
WORKDIR /package
COPY --from=backend-kubeflow-wheel /src .
RUN pip3 install .
WORKDIR /src
COPY ./volumes/backend/requirements.txt .
RUN pip3 install -r requirements.txt
COPY ./volumes/backend/apps/ ./apps
COPY ./volumes/backend/entrypoint.py .
COPY --from=frontend /src/dist/default/ /src/apps/default/static/
COPY --from=frontend /src/dist/rok/ /src/apps/rok/static/
ENTRYPOINT ["/bin/bash","-c","gunicorn -w 3 --bind 0.0.0.0:5000 --access-logfile - entrypoint:app"]

View File

@ -0,0 +1,11 @@
# See: https://github.com/moby/moby/issues/12886#issuecomment-480575928
# Ignore everything and node_modules
**
#
# Don't ignore the necessary files for the app
!common/
!volumes/
# Ignore node_modules and pycache inside accepted dirs
**/node_modules/*
**/__pycache__/*

View File

@ -0,0 +1,15 @@
IMG ?= gcr.io/arrikto-playground/kubeflow/volumes-web-app
TAG ?= $(shell git describe --tags)
DOCKERFILE=volumes/Dockerfile
docker-build:
cp Dockerfile.dockerignore ../.dockerignore
-cd ../ && docker build -t ${IMG}:${TAG} -f ${DOCKERFILE} .
rm ../.dockerignore
docker-push:
docker push $(IMG):$(TAG)
image: docker-build docker-push
@echo "Updated image ${IMG}"

View File

@ -0,0 +1,42 @@
# Volumes web app
This web app is responsible for allowing the user to manipulate PVCs in their Kubeflow cluster. To achieve this it provides a user friendly way to handle the lifecycle of PVC objects.
## Development
Requirements:
* node 12.0.0
* python 3.7
### Frontend
```bash
# build the common library
cd components/crud-web-apps/common/frontend/kubeflow-common-lib
npm i
npm run build
cd dist/kubeflow
npm link
# build the app frontend
cd ../../../volumes/frontend
npm i
npm link kubeflow
npm run build:watch
```
### Backend
```bash
# create a virtual env and install deps
# https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/
cd component/crud-web-apps/volumes/backend
python3.7 -m pip install --user virtualenv
python3.7 -m venv web-apps-dev
source web-apps-dev/bin/activate
# install the deps on the activated virtual env
make -C backend install-deps
# run the backend
make -C backend run-dev
```

View File

@ -0,0 +1,28 @@
SHELL=bash
install-deps:
pushd ../../../py && \
pip install -e . && \
popd
pip install -r requirements.txt
run:
APP_PREFIX=/volumes \
gunicorn -w 3 --bind 0.0.0.0:5000 --access-logfile - entrypoint:app
run-rok:
UI_FLAVOR=rok \
APP_PREFIX=/volumes \
gunicorn -w 3 --bind 0.0.0.0:5000 --access-logfile - entrypoint:app
run-dev:
UI_FLAVOR=default \
BACKEND_MODE=dev \
APP_PREFIX=/ \
gunicorn -w 3 --bind 0.0.0.0:5000 --access-logfile - entrypoint:app
run-dev-rok:
UI_FLAVOR=rok \
BACKEND_MODE=dev \
APP_PREFIX=/ \
gunicorn -w 3 --bind 0.0.0.0:5000 --access-logfile - entrypoint:app

View File

@ -0,0 +1,13 @@
import kubeflow.kubeflow.crud_backend as base
from kubeflow.kubeflow.crud_backend import config, logging
log = logging.getLogger(__name__)
def create_app(name=__name__, static_folder="static",
cfg: config.Config = None):
cfg = config.Config() if cfg is None else cfg
app = base.create_app(name, static_folder, cfg)
return app

View File

@ -0,0 +1,38 @@
from kubernetes import client
def handle_storage_class(vol):
"""
vol: dict (send from the frontend)
If the fronend sent the special values `{none}` or `{empty}` then the
backend will need to set the corresponding storage_class value that the
python client expects.
"""
if "class" not in vol:
return None
if vol["class"] == "{none}":
return ""
if vol["class"] == "{empty}":
return None
else:
return vol["class"]
def pvc_from_dict(body, namespace):
"""
body: json object (frontend json data)
Convert the PVC json object that is sent from the backend to a python
client PVC instance.
"""
return client.V1PersistentVolumeClaim(
metadata=client.V1ObjectMeta(name=body["name"], namespace=namespace),
spec=client.V1PersistentVolumeClaimSpec(
access_modes=[body["mode"]],
storage_class_name=handle_storage_class(body),
resources=client.V1ResourceRequirements(
requests={"storage": body["size"]}
),
),
)

View File

@ -0,0 +1,67 @@
from kubeflow.kubeflow.crud_backend import api, status
def pvc_status(pvc):
"""
Set the status of the pvc
"""
if pvc.metadata.deletion_timestamp is not None:
return status.create_status(
status.STATUS_PHASE.TERMINATING, "Deleting Volume..."
)
if pvc.status.phase == "Bound":
return status.create_status(status.STATUS_PHASE.READY, "Bound")
# The PVC is in Pending state, we check the Events to find out why
evs = api.v1_core.list_namespaced_event(
namespace=pvc.metadata.namespace,
field_selector=api.events_field_selector(
"PersistentVolumeClaim", pvc.metadata.name
),
).items
# If there are no events, then the PVC was just created
if len(evs) == 0:
return status.create_status(
status.STATUS_PHASE.WAITING, "Provisioning Volume..."
)
msg = f"Pending: {evs[0].message}"
state = evs[0].reason
if evs[0].reason == "WaitForFirstConsumer":
phase = status.STATUS_PHASE.UNAVAILABLE
msg = (
"Pending: This volume will be bound when its first consumer"
+ " is created. E.g., when you first browse its contents, or"
+ " attach it to a notebook server"
)
elif evs[0].reason == "Provisioning":
phase = status.STATUS_PHASE.WAITING
elif evs[0].reason == "FailedBinding":
phase = status.STATUS_PHASE.WARNING
elif evs[0].type == "Warning":
phase = status.STATUS_PHASE.WARNING
elif evs[0].type == "Normal":
phase = status.STATUS_PHASE.READY
return status.create_status(phase, msg, state)
def viewer_status(viewer):
"""
Return a string representing the status of that viewer. If a deletion
timestamp is set we want to return a `Terminating` state.
"""
try:
ready = viewer["status"]["ready"]
except KeyError:
return status.STATUS_PHASE.UNINITIALIZED
if "deletionTimestamp" in viewer["metadata"]:
return status.STATUS_PHASE.TERMINATING
if not ready:
return status.STATUS_PHASE.WAITING
return status.STATUS_PHASE.READY

View File

@ -0,0 +1,68 @@
from kubeflow.kubeflow.crud_backend import api, helpers
from . import status
def parse_pvc(pvc):
"""
pvc: client.V1PersistentVolumeClaim
Process the PVC and format it as the UI expects it.
"""
try:
capacity = pvc.status.capacity["storage"]
except Exception:
capacity = pvc.spec.resources.requests["storage"]
parsed_pvc = {
"name": pvc.metadata.name,
"namespace": pvc.metadata.namespace,
"status": status.pvc_status(pvc),
"age": {
"uptime": helpers.get_uptime(pvc.metadata.creation_timestamp),
"timestamp": pvc.metadata.creation_timestamp.strftime(
"%d/%m/%Y, %H:%M:%S"
),
},
"capacity": capacity,
"modes": pvc.spec.access_modes,
"class": pvc.spec.storage_class_name,
}
return parsed_pvc
def get_pods_using_pvc(pvc, namespace):
"""
Return a list of Pods that are using the given PVC
"""
pods = api.list_pods(namespace)
mounted_pods = []
for pod in pods.items:
pvcs = get_pod_pvcs(pod)
if pvc in pvcs:
mounted_pods.append(pod)
return mounted_pods
def get_pod_pvcs(pod):
"""
Return a list of PVC name that the given Pod
is using. If it doesn't use any, then an empty list will
be returned.
"""
pvcs = []
if not pod.spec.volumes:
return []
vols = pod.spec.volumes
for vol in vols:
# Check if the volume is a pvc
if not vol.persistent_volume_claim:
continue
pvcs.append(vol.persistent_volume_claim.claim_name)
return pvcs

View File

@ -0,0 +1,27 @@
import os
from kubeflow.kubeflow.crud_backend import config, logging
from ..common import create_app as create_default_app
from .routes import bp as routes_bp
log = logging.getLogger(__name__)
def create_app(name=__name__, cfg: config.Config = None):
cfg = config.Config() if cfg is None else cfg
# Properly set the static serving directory
static_dir = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "static"
)
app = create_default_app(name, static_dir, cfg)
log.info("Setting STATIC_DIR to: " + static_dir)
app.config["STATIC_DIR"] = static_dir
# Register the app's blueprints
app.register_blueprint(routes_bp)
return app

View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint("default_routes", __name__)
from . import delete, get, post # noqa: F401, E402

View File

@ -0,0 +1,27 @@
from werkzeug import exceptions
from kubeflow.kubeflow.crud_backend import api, logging
from ...common import utils as common_utils
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc>", methods=["DELETE"])
def delete_pvc(pvc, namespace):
"""
Delete a PVC only if it is not used from any Pod
"""
pods = common_utils.get_pods_using_pvc(pvc, namespace)
if pods:
pod_names = [p.metadata.name for p in pods]
raise exceptions.Conflict("Cannot delete PVC '%s' because it is being"
" used by pods: %s" % (pvc, pod_names))
log.info("Deleting PVC %s/%s...", namespace, pvc)
api.delete_pvc(pvc, namespace)
log.info("Successfully deleted PVC %s/%s", namespace, pvc)
return api.success_response("message",
"PVC %s successfully deleted." % pvc)

View File

@ -0,0 +1,15 @@
from kubeflow.kubeflow.crud_backend import api, logging
from ...common import utils
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/pvcs")
def get_pvcs(namespace):
# Return the list of PVCs
pvcs = api.list_pvcs(namespace)
content = [utils.parse_pvc(pvc) for pvc in pvcs.items]
return api.success_response("pvcs", content)

View File

@ -0,0 +1,24 @@
from flask import request
from kubeflow.kubeflow.crud_backend import api, decorators, logging
from ...common import form
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/pvcs", methods=["POST"])
@decorators.request_is_json_type
@decorators.required_body_params("name", "mode", "class", "size", "type")
def post_pvc(namespace):
body = request.get_json()
log.info("Received body: %s", body)
pvc = form.pvc_from_dict(body, namespace)
log.info("Creating PVC '%s'...", pvc)
api.create_pvc(pvc, namespace)
log.info("Successfully created PVC %s/%s", namespace, pvc.metadata.name)
return api.success_response("message", "PVC created successfully.")

View File

@ -0,0 +1,28 @@
import os
from kubeflow.kubeflow.crud_backend import config, logging, rok
from ..common import create_app as create_default_app
from .routes import bp as routes_bp
log = logging.getLogger(__name__)
def create_app(name=__name__, cfg: config.Config = None):
cfg = config.Config() if cfg is None else cfg
# Properly set the static serving directory
static_dir = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "static"
)
app = create_default_app(name, static_dir, cfg)
log.info("Setting STATIC_DIR to: " + static_dir)
app.config["STATIC_DIR"] = static_dir
# Register the app's blueprints
app.register_blueprint(rok.bp)
app.register_blueprint(routes_bp)
return app

View File

@ -0,0 +1,7 @@
apiVersion: kubeflow.org/v1alpha1
kind: PVCViewer
metadata:
name: {name}
namespace: {namespace}
spec:
pvc: {name}

View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint("rok_routes", __name__)
from . import get, post, delete # noqa: F401, E402

View File

@ -0,0 +1,31 @@
from werkzeug import exceptions
from kubeflow.kubeflow.crud_backend import api, logging
from ...common import utils as common_utils
from .. import utils as rok_utils
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/pvcs/<pvc>", methods=["DELETE"])
def delete_pvc(pvc, namespace):
"""
Delete a PVC, even if it is only mounted on PVCViewer Pods.
Get list of PVCViewers that use the requested PVC. If no other Pods
are using that PVC then delete the Viewer Pods as well as the PVC.
"""
pods = common_utils.get_pods_using_pvc(pvc, namespace)
non_viewer_pods = [p for p in pods if not rok_utils.is_viewer_pod(p)]
if non_viewer_pods:
pod_names = [p.metadata.name for p in non_viewer_pods]
raise exceptions.Conflict("Cannot delete PVC '%s' because it is being"
" used by pods: %s" % (pvc, pod_names))
log.info("Deleting PVC %s/%s...", namespace, pvc)
api.delete_pvc(pvc, namespace)
log.info("Successfully deleted PVC %s/%s", namespace, pvc)
return api.success_response("message",
"PVC %s successfully deleted." % pvc)

View File

@ -0,0 +1,25 @@
from kubeflow.kubeflow.crud_backend import api, logging
from ...common import status
from .. import utils
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/pvcs")
def get_pvcs(namespace):
# Get the active viewers for each pvc as a dictionary
# with Key:PVC name and Value:Status of Viewer
viewers_lst = api.list_custom_rsrc(*utils.PVCVIEWER, namespace)
viewers = {}
for v in viewers_lst["items"]:
pvc_name = v["spec"]["pvc"]
viewers[pvc_name] = status.viewer_status(v)
# Return the list of PVCs and the corresponding Viewer's state
pvcs = api.list_pvcs(namespace)
content = [utils.parse_pvc(pvc, viewers) for pvc in pvcs.items]
return api.success_response("pvcs", content)

View File

@ -0,0 +1,44 @@
from flask import request
from kubeflow.kubeflow.crud_backend import api, decorators, logging
from ...common import form
from .. import utils as rok_utils
from . import bp
log = logging.getLogger(__name__)
@bp.route("/api/namespaces/<namespace>/viewers", methods=["POST"])
@decorators.request_is_json_type
@decorators.required_body_params("name")
def post_viewer(namespace):
body = request.get_json()
log.info("Received body: %s", body)
name = body["name"]
viewer = rok_utils.load_pvcviewer_yaml_template(name=name,
namespace=namespace)
log.info("Creating PVCViewer '%s'...", viewer)
api.create_custom_rsrc(*rok_utils.PVCVIEWER, viewer, namespace)
log.info("Successfully created PVCViewer %s/%s", namespace, name)
return api.success_response("message", "PVCViewer created successfully.")
@bp.route("/api/namespaces/<namespace>/pvcs", methods=["POST"])
@decorators.request_is_json_type
@decorators.required_body_params("name", "mode", "class", "size", "type")
def post_pvc(namespace):
body = request.get_json()
log.info("Received body: ", body)
pvc = form.pvc_from_dict(body, namespace)
rok_utils.add_pvc_rok_annotations(pvc, body)
log.info("Creating PVC '%s'...", pvc)
api.create_pvc(pvc, namespace)
log.info("Successfully created PVC %s/%s", namespace, pvc.metadata.name)
return api.success_response("message", "PVC created successfully.")

View File

@ -0,0 +1,87 @@
import os
from kubeflow.kubeflow.crud_backend import helpers, status
from ..common import utils as common_utils
KIND = "PVCViewer"
GROUP = "kubeflow.org"
VERSION = "v1alpha1"
PLURAL = "pvcviewers"
PVCVIEWER = [GROUP, VERSION, PLURAL]
PVCVIEWER_YAML = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "pvcviewer.yaml"
)
def load_pvcviewer_yaml_template(**kwargs):
"""
kwargs: the parameters to be replaced in the yaml
Reads the yaml for the web app's custom resource, replaces the variables
and returns it as a python dict.
"""
return helpers.load_param_yaml(PVCVIEWER_YAML, **kwargs)
def add_pvc_rok_annotations(pvc, body):
"""Set the necessary Rok annotations"""
annotations = pvc.metadata.annotations or {}
if body["type"] == "rok_snapshot" and "snapshot" in body:
annotations["rok/origin"] = body["snapshot"]
labels = pvc.metadata.labels or {}
labels["component"] = "singleuser-storage"
pvc.metadata.annotations = annotations
pvc.metadata.labels = labels
def parse_pvc(pvc, viewers):
"""
pvc: client.V1PersistentVolumeClaim
viewers: dict(Key:PVC Name, Value: Viewer's Status)
Process the PVC and format it as the UI expects it. If a Viewer is active
for that PVC, then include this information
"""
parsed_pvc = common_utils.parse_pvc(pvc)
parsed_pvc["viewer"] = viewers.get(pvc.metadata.name,
status.STATUS_PHASE.UNINITIALIZED)
return parsed_pvc
def get_viewer_owning_pod(pod):
"""
Return a list of PVCViewer names that own the Pod
"""
owner_refs = pod.metadata.owner_references
for owner_ref in owner_refs:
if owner_ref.kind == KIND:
return owner_ref.name
return None
def is_viewer_pod(pod):
"""
Checks if the given Pod belongs to a PVCViewer
"""
return get_viewer_owning_pod(pod) is not None
def get_viewers_owning_pods(pods):
"""
Return the name of PVCViewers that own a subset of the given Pods
"""
viewers = []
for pod in pods:
if not is_viewer_pod(pod):
continue
viewers.append(get_viewer_owning_pod(pod))
return viewers

View File

@ -0,0 +1,45 @@
import os
import sys
from apps import default, rok
from kubeflow.kubeflow.crud_backend import config, logging
log = logging.getLogger(__name__)
def get_config(mode):
"""Return a config based on the selected mode."""
config_classes = {
config.BackendMode.DEVELOPMENT.value: config.DevConfig,
config.BackendMode.DEVELOPMENT_FULL.value: config.DevConfig,
config.BackendMode.PRODUCTION.value: config.ProdConfig,
config.BackendMode.PRODUCTION_FULL.value: config.ProdConfig,
}
cfg_class = config_classes.get(mode)
if not cfg_class:
raise RuntimeError("Backend mode '%s' is not implemented. Choose one"
" of %s" % (mode, list(config_classes.keys())))
return cfg_class()
APP_NAME = os.environ.get("APP_NAME", "Volumes Web App")
BACKEND_MODE = os.environ.get("BACKEND_MODE",
config.BackendMode.PRODUCTION.value)
UI_FLAVOR = os.environ.get("UI_FLAVOR", "default")
PREFIX = os.environ.get("APP_PREFIX", "/")
cfg = get_config(BACKEND_MODE)
cfg.PREFIX = PREFIX
# Load the app based on UI_FLAVOR env var
if UI_FLAVOR == "default":
app = default.create_app(APP_NAME, cfg)
elif UI_FLAVOR == "rok":
app = rok.create_app(APP_NAME, cfg)
else:
log.error("No UI flavor for '%s'", UI_FLAVOR)
sys.exit(1)
if __name__ == "__main__":
app.run()

View File

@ -0,0 +1 @@
gunicorn

View File

@ -0,0 +1,13 @@
# Editor configuration, see https://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

View File

@ -0,0 +1,51 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
speed-measure-plugin*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
# Rok files
**/browse-in-rok-gray.svg
**/browse-in-rok-grey.svg
**/browse-in-rok-blue.svg

View File

@ -0,0 +1,11 @@
{
"printWidth": 80,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid",
"proseWrap": "preserve"
}

View File

@ -0,0 +1,27 @@
# Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.3.20.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@ -0,0 +1,160 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"preserveSymlinks": true,
"outputPath": "dist/frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": false,
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": [],
"crossOrigin": "use-credentials"
},
"configurations": {
"rok": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.rok.ts"
}
]
},
"rok-prod": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.rok.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
},
"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,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "frontend:build"
},
"configurations": {
"production": {
"browserTarget": "frontend:build:production"
},
"rok": {
"browserTarget": "frontend:build:rok"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": ["**/node_modules/**"]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "frontend:serve"
},
"configurations": {
"production": {
"devServerTarget": "frontend:serve:production"
}
}
}
}
}
},
"defaultProject": "frontend"
}

View File

@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

View File

@ -0,0 +1,32 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
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.json')
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@ -0,0 +1,23 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('frontend app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

View File

@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get(browser.baseUrl) as Promise<any>;
}
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>;
}
}

View File

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

View File

@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/frontend'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"build": "npm run copyLibAssets && ng build --prod --base-href /volumes/ --deploy-url static/",
"build:watch": "npm run copyLibAssets && ng build --watch --deploy-url static/ --outputPath ../backend/apps/default/static/ --outputHashing all",
"build:watch:rok": "npm run copyLibAssets && ng build --watch --deploy-url static/ --configuration=rok --outputPath ../backend/apps/rok/static/ --outputHashing all",
"copyLibAssets": "cp ./node_modules/kubeflow/assets/* ./src/assets/",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "~8.2.14",
"@angular/cdk": "^8.2.3",
"@angular/common": "~8.2.14",
"@angular/compiler": "~8.2.14",
"@angular/cdk-experimental": "^8.2.3",
"@angular/core": "~8.2.14",
"@angular/forms": "~8.2.14",
"@angular/material": "^8.2.3",
"@angular/platform-browser": "~8.2.14",
"@angular/platform-browser-dynamic": "~8.2.14",
"@angular/router": "~8.2.14",
"@fortawesome/angular-fontawesome": "^0.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"date-fns": "^1.29.0",
"lodash-es": "^4.17.11",
"hammerjs": "^2.0.8",
"lodash": "^4.17.15",
"material-icons": "^0.3.1",
"raw-loader": "^4.0.0",
"rxjs": "~6.4.0",
"tslib": "^1.10.0",
"zone.js": "~0.9.1",
"@kubernetes/client-node": "^0.12.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.803.20",
"@angular/cli": "~8.3.20",
"@angular/compiler-cli": "~8.2.14",
"@angular/language-service": "~8.2.14",
"@types/node": "~8.9.4",
"@types/jasmine": "~3.3.8",
"@types/jasminewd2": "~2.0.3",
"codelyzer": "^5.0.0",
"jasmine-core": "~3.4.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~4.1.0",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~2.0.1",
"karma-jasmine": "~2.0.1",
"karma-jasmine-html-reporter": "^1.4.0",
"protractor": "~5.4.0",
"ts-node": "~7.0.0",
"tslint": "~5.15.0",
"typescript": "~3.5.3"
}
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { IndexComponent } from './pages/index/index.component';
const routes: Routes = [{ path: '', component: IndexComponent }];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

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

View File

@ -0,0 +1,33 @@
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
declarations: [AppComponent],
}).compileComponents();
}));
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'frontend'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain(
'frontend app is running!',
);
});
});

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {}

View File

@ -0,0 +1,50 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ErrorStateMatcher } from '@angular/material/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {
ResourceTableModule,
NamespaceSelectModule,
ConfirmDialogModule,
FormModule,
ImmediateErrorStateMatcher,
KubeflowModule,
} from 'kubeflow';
import { IndexComponent } from './pages/index/index.component';
import { FormDefaultComponent } from './pages/form/form-default/form-default.component';
import { FormRokComponent } from './pages/form/form-rok/form-rok.component';
import { IndexDefaultComponent } from './pages/index/index-default/index-default.component';
import { IndexRokComponent } from './pages/index/index-rok/index-rok.component';
@NgModule({
declarations: [
AppComponent,
IndexComponent,
FormDefaultComponent,
FormRokComponent,
IndexDefaultComponent,
IndexRokComponent,
],
imports: [
BrowserModule,
CommonModule,
AppRoutingModule,
ResourceTableModule,
NamespaceSelectModule,
ConfirmDialogModule,
FormModule,
KubeflowModule,
],
providers: [
{ provide: ErrorStateMatcher, useClass: ImmediateErrorStateMatcher },
],
bootstrap: [AppComponent],
entryComponents: [FormDefaultComponent, FormRokComponent],
})
export class AppModule {}

View File

@ -0,0 +1,72 @@
<div class="form--container" mat-dialog-content>
<form novalidate (ngSubmit)="onSubmit()" [formGroup]="formCtrl">
<lib-form-section
title="New Volume"
text="Create a new empty Volume."
icon="fa:fas:hdd"
>
</lib-form-section>
<!--Name / Namespace-->
<lib-form-name-namespace-inputs
[nameControl]="formCtrl.get('name')"
[namespaceControl]="formCtrl.get('namespace')"
resourceName="Volume"
[existingNames]="pvcNames"
>
</lib-form-name-namespace-inputs>
<!--Snapshot Chooser-->
<mat-form-field
*ngIf="formCtrl.get('type').value === 'snapshot'"
appearance="outline"
class="wide"
>
<mat-label>Snapshot</mat-label>
<mat-select formControlName="snapshot"> </mat-select>
</mat-form-field>
<!--Size-->
<lib-positive-number-input
[sizeControl]="formCtrl.get('size')"
min="1"
step="1"
label="Volume size in Gi"
></lib-positive-number-input>
<!--Storage Class-->
<mat-form-field appearance="outline" class="wide">
<mat-label>Storage Class</mat-label>
<mat-select formControlName="class">
<mat-option value="{none}">None</mat-option>
<mat-option *ngFor="let sc of storageClasses" [value]="sc">
{{ sc }}
</mat-option>
</mat-select>
</mat-form-field>
<!--Access Mode-->
<mat-form-field appearance="outline" class="wide">
<mat-label>Access Mode</mat-label>
<mat-select formControlName="mode">
<mat-option value="ReadWriteOnce">ReadWriteOnce</mat-option>
<mat-option value="ReadOnlyMany">ReadOnlyMany</mat-option>
<mat-option value="ReadWriteMany">ReadWriteMany</mat-option>
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
class="form--button-margin"
type="submit"
[disabled]="!formCtrl.valid || blockSubmit"
>
CREATE
</button>
<button mat-raised-button type="button" (click)="onCancel()">
CANCEL
</button>
</form>
</div>

View File

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

View File

@ -0,0 +1,104 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import {
FormBuilder,
FormGroup,
Validators,
FormControl,
ValidatorFn,
} from '@angular/forms';
import { Subscription } from 'rxjs';
import {
NamespaceService,
getExistingNameValidator,
dns1035Validator,
getNameError,
DIALOG_RESP,
} from 'kubeflow';
import { VWABackendService } from 'src/app/services/backend.service';
import { PVCPostObject } from 'src/app/types';
import { MatDialogRef } from '@angular/material';
@Component({
selector: 'app-form-default',
templateUrl: './form-default.component.html',
styleUrls: ['./form-default.component.scss'],
})
export class FormDefaultComponent implements OnInit, OnDestroy {
public TYPE_ROK_SNAPSHOT = 'rok_snapshot';
public TYPE_EMPTY = 'empty';
public subs = new Subscription();
public formCtrl: FormGroup;
public blockSubmit = false;
public currNamespace = '';
public pvcNames = new Set<string>();
public storageClasses: string[] = [];
public defaultStorageClass: string;
constructor(
public ns: NamespaceService,
public fb: FormBuilder,
public backend: VWABackendService,
public dialog: MatDialogRef<FormDefaultComponent>,
) {
this.formCtrl = this.fb.group({
type: ['empty', [Validators.required]],
name: ['', [Validators.required]],
namespace: ['', [Validators.required]],
size: [10, []],
class: ['$empty', [Validators.required]],
mode: ['ReadWriteOnce', [Validators.required]],
});
}
ngOnInit() {
this.formCtrl.controls.namespace.disable();
this.backend.getStorageClasses().subscribe(storageClasses => {
this.storageClasses = storageClasses;
// Once we have the list of storage classes, get the
// default one from the backend and make it the preselected
this.backend.getDefaultStorageClass().subscribe(defaultClass => {
this.defaultStorageClass = defaultClass;
this.formCtrl.controls.class.setValue(defaultClass);
});
});
this.subs.add(
this.ns.getSelectedNamespace().subscribe(ns => {
this.currNamespace = ns;
this.formCtrl.controls.namespace.setValue(ns);
this.backend.getPVCs(ns).subscribe(pvcs => {
this.pvcNames.clear();
pvcs.forEach(pvc => this.pvcNames.add(pvc.name));
});
}),
);
}
ngOnDestroy() {
this.subs.unsubscribe();
}
public onSubmit() {
const pvc: PVCPostObject = JSON.parse(JSON.stringify(this.formCtrl.value));
pvc.size = pvc.size + 'Gi';
this.blockSubmit = true;
this.backend.createPVC(this.currNamespace, pvc).subscribe(
result => {
this.dialog.close(DIALOG_RESP.ACCEPT);
},
error => {
this.blockSubmit = false;
},
);
}
public onCancel() {
this.dialog.close(DIALOG_RESP.CANCEL);
}
}

View File

@ -0,0 +1,95 @@
<div class="form--container" mat-dialog-content>
<form novalidate (ngSubmit)="onSubmit()" [formGroup]="formCtrl">
<lib-form-section
title="New Volume"
text="Create a new empty Volume or clone an existing snapshot."
icon="fa:fas:hdd"
>
</lib-form-section>
<!--Volume Type-->
<mat-form-field appearance="outline" class="wide">
<mat-label>Volume Type</mat-label>
<mat-select formControlName="type">
<mat-option [value]="TYPE_EMPTY">Empty Volume</mat-option>
<mat-option [value]="TYPE_ROK_SNAPSHOT">
Clone an existing Rok snapshot
</mat-option>
</mat-select>
</mat-form-field>
<!--Name / Namespace-->
<lib-form-name-namespace-inputs
[nameControl]="formCtrl.get('name')"
[namespaceControl]="formCtrl.get('namespace')"
resourceName="Volume"
[existingNames]="pvcNames"
>
</lib-form-name-namespace-inputs>
<!--Snapshot Chooser-->
<lib-rok-url-input
*ngIf="formCtrl.get('type').value === TYPE_ROK_SNAPSHOT"
[control]="formCtrl.controls.snapshot"
[mode]="'file'"
(urlEntered)="rokUrlChanged($event)"
></lib-rok-url-input>
<!--Size-->
<lib-positive-number-input
[sizeControl]="formCtrl.get('size')"
min="1"
step="1"
label="Volume size in Gi"
></lib-positive-number-input>
<!--Storage Class-->
<mat-form-field appearance="outline" class="wide">
<mat-label>Storage Class</mat-label>
<mat-select formControlName="class">
<mat-option
value="{none}"
[disabled]="classIsDisabled('{none}')"
[matTooltip]="classTooltip('{none}')"
>
None</mat-option
>
<mat-option
*ngFor="let sc of storageClasses"
[value]="sc"
[disabled]="classIsDisabled(sc)"
[matTooltip]="classTooltip(sc)"
>
{{ sc }}
</mat-option>
</mat-select>
<mat-error *ngIf="formCtrl.controls.class.hasError('notRokClass')">
Must use a Rok provided Storage Class when cloning a snapshot from Rok
</mat-error>
</mat-form-field>
<!--Access Mode-->
<mat-form-field appearance="outline" class="wide">
<mat-label>Access Mode</mat-label>
<mat-select formControlName="mode">
<mat-option value="ReadWriteOnce">ReadWriteOnce</mat-option>
<mat-option value="ReadOnlyMany">ReadOnlyMany</mat-option>
<mat-option value="ReadWriteMany">ReadWriteMany</mat-option>
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
class="form--button-margin"
type="submit"
[disabled]="!formCtrl.valid || blockSubmit"
>
CREATE
</button>
<button mat-raised-button type="button" (click)="onCancel()">
CANCEL
</button>
</form>
</div>

View File

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

View File

@ -0,0 +1,172 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, Validators, FormControl } from '@angular/forms';
import {
NamespaceService,
RokService,
DIALOG_RESP,
SnackBarService,
SnackType,
rokUrlValidator,
updateNonDirtyControl,
} from 'kubeflow';
import { VWABackendService } from 'src/app/services/backend.service';
import { PVCPostObject } from 'src/app/types';
import { MatDialogRef } from '@angular/material';
import { FormDefaultComponent } from '../form-default/form-default.component';
import { environment } from '@app/environment';
import { rokStorageClassValidator } from './utils';
import { concatMap } from 'rxjs/operators';
@Component({
selector: 'app-form-rok',
templateUrl: './form-rok.component.html',
styleUrls: [
'../form-default/form-default.component.scss',
'./form-rok.component.scss',
],
})
// TODO: Use an abstract class to eliminate common code
export class FormRokComponent extends FormDefaultComponent
implements OnInit, OnDestroy {
public env = environment;
private rokManagedStorageClasses: string[] = [];
constructor(
public ns: NamespaceService,
public fb: FormBuilder,
public backend: VWABackendService,
public dialog: MatDialogRef<FormDefaultComponent>,
public snack: SnackBarService,
public rok: RokService,
) {
super(ns, fb, backend, dialog);
}
ngOnInit() {
super.ngOnInit();
this.formCtrl.get('size').setValue(5);
this.subs.add(
this.formCtrl.get('type').valueChanges.subscribe(volType => {
if (volType === this.TYPE_EMPTY) {
this.formCtrl.removeControl('snapshot');
this.formCtrl.get('class').setValidators([Validators.required]);
updateNonDirtyControl(this.formCtrl.get('size'), 5);
updateNonDirtyControl(
this.formCtrl.get('class'),
this.defaultStorageClass,
);
} else {
// Add a Rok URL control
this.formCtrl.addControl(
'snapshot',
new FormControl(
'',
[Validators.required],
[rokUrlValidator(this.rok)],
),
);
// Set the size value to none to force the user to handle this field
updateNonDirtyControl(this.formCtrl.get('size'), null);
// Ensure that the StorageClass used is provisioned from Rok
this.formCtrl
.get('class')
.setValidators([
Validators.required,
rokStorageClassValidator(this.rokManagedStorageClasses),
]);
}
this.formCtrl.get('class').updateValueAndValidity({ onlySelf: true });
}),
);
// Get the list of Rok managed storage classes
this.rok.getRokManagedStorageClasses().subscribe(classes => {
this.rokManagedStorageClasses = classes;
// update the validators if type is Rok Snapshot
if (this.formCtrl.get('type').value === this.TYPE_ROK_SNAPSHOT) {
this.formCtrl
.get('class')
.setValidators([
Validators.required,
rokStorageClassValidator(this.rokManagedStorageClasses),
]);
}
});
this.rok.initCSRF();
}
public rokUrlChanged(url: string) {
this.rok.getObjectMetadata(url).subscribe({
next: headers => {
// Autofill the name
let size = parseInt(headers.get('content-length'), 10);
size = size / Math.pow(1024, 3);
this.formCtrl.get('size').setValue(size);
this.snack.open(
'Successfully retrieved snapshot information.',
SnackType.Success,
3000,
);
},
error: err => {
this.snack.open(`'${url}' is not a valid Rok URL`, SnackType.Error, -1);
},
});
}
/*
* Handle the Storage Classes Select state
*/
public classIsDisabled(name: string) {
const volType = this.formCtrl.controls.type.value;
if (volType !== this.TYPE_ROK_SNAPSHOT) {
return false;
}
if (this.rokManagedStorageClasses.includes(name)) {
return false;
}
return true;
}
public classTooltip(name: string) {
if (!this.classIsDisabled(name)) {
return '';
}
return 'This Storage Class is not backed by Rok';
}
public onSubmit() {
// TODO: Could use a lodash helper instead
const pvc: PVCPostObject = JSON.parse(JSON.stringify(this.formCtrl.value));
pvc.size = pvc.size + 'Gi';
this.blockSubmit = true;
// Check if the Rok URL is valid
if (pvc.type === this.TYPE_ROK_SNAPSHOT) {
this.rok.getObjectMetadata(pvc.snapshot, false).subscribe(headers => {
this.backend.createPVC(this.currNamespace, pvc).subscribe(result => {
this.dialog.close(DIALOG_RESP.ACCEPT);
});
});
return;
}
this.backend.createPVC(this.currNamespace, pvc).subscribe(result => {
this.dialog.close(DIALOG_RESP.ACCEPT);
});
this.blockSubmit = false;
}
}

View File

@ -0,0 +1,14 @@
import { ValidatorFn, FormControl } from '@angular/forms';
export function rokStorageClassValidator(
rokManagedClasses: string[],
): ValidatorFn {
return (control: FormControl): { [key: string]: any } => {
const currentClass = control.value;
if (!rokManagedClasses.includes(currentClass)) {
return { notRokClass: true };
}
return null;
};
}

View File

@ -0,0 +1,54 @@
import {
PropertyValue,
StatusValue,
ActionListValue,
ActionIconValue,
TRUNCATE_TEXT_SIZE,
TableConfig,
} from 'kubeflow';
export const tableConfig: TableConfig = {
title: 'Volumes',
newButtonText: 'NEW VOLUME',
columns: [
{
matHeaderCellDef: 'Status',
matColumnDef: 'status',
value: new StatusValue(),
},
{
matHeaderCellDef: 'Name',
matColumnDef: 'name',
value: new PropertyValue({
field: 'name',
tooltipField: 'name',
truncate: TRUNCATE_TEXT_SIZE.SMALL,
}),
},
{
matHeaderCellDef: 'Age',
matColumnDef: 'age',
value: new PropertyValue({
field: 'age.uptime',
tooltipField: 'age.timestamp',
}),
},
{
matHeaderCellDef: 'Size',
matColumnDef: 'size',
value: new PropertyValue({ field: 'capacity' }),
},
{
matHeaderCellDef: 'Access Mode',
matColumnDef: 'modes',
value: new PropertyValue({ field: 'modes' }),
},
{
matHeaderCellDef: 'Storage Class',
matColumnDef: 'class',
value: new PropertyValue({ field: 'class' }),
},
// the apps should import the actions they want
],
};

View File

@ -0,0 +1,22 @@
import { ActionListValue, ActionIconValue, TableColumn, TableConfig } from 'kubeflow';
import { tableConfig } from '../config';
const actionsCol: TableColumn = {
matHeaderCellDef: '',
matColumnDef: 'actions',
value: new ActionListValue([
new ActionIconValue({
name: 'delete',
tooltip: 'Delete Volume',
color: 'warn',
field: 'deleteAction',
iconReady: 'material:delete',
}),
]),
};
export const defaultConfig: TableConfig = {
title: tableConfig.title,
newButtonText: tableConfig.newButtonText,
columns: tableConfig.columns.concat(actionsCol),
};

View File

@ -0,0 +1,8 @@
<lib-namespace-select *ngIf="!env.production"></lib-namespace-select>
<lib-resource-table
[config]="config"
[data]="processedData"
[trackByFn]="pvcTrackByFn"
(actionsEmitter)="reactToAction($event)"
></lib-resource-table>

View File

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

View File

@ -0,0 +1,181 @@
import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material';
import {
NamespaceService,
ActionEvent,
ConfirmDialogService,
ExponentialBackoff,
STATUS_TYPE,
DIALOG_RESP,
DialogConfig,
SnackBarService,
SnackType,
} from 'kubeflow';
import { defaultConfig } from './config';
import { environment } from '@app/environment';
import { VWABackendService } from 'src/app/services/backend.service';
import { PVCResponseObject, PVCProcessedObject } from 'src/app/types';
import { Subscription, Observable, Subject } from 'rxjs';
import { isEqual } from 'lodash';
import { FormDefaultComponent } from '../../form/form-default/form-default.component';
@Component({
selector: 'app-index-default',
templateUrl: './index-default.component.html',
styleUrls: ['./index-default.component.scss'],
})
export class IndexDefaultComponent implements OnInit {
public env = environment;
public poller: ExponentialBackoff;
public currNamespace = '';
public subs = new Subscription();
public config = defaultConfig;
public rawData: PVCResponseObject[] = [];
public processedData: PVCProcessedObject[] = [];
public pvcsWaitingViewer = new Set<string>();
constructor(
public ns: NamespaceService,
public confirmDialog: ConfirmDialogService,
public backend: VWABackendService,
public dialog: MatDialog,
public snackBar: SnackBarService,
) {}
ngOnInit() {
this.poller = new ExponentialBackoff({ interval: 1000, retries: 3 });
// Poll for new data and reset the poller if different data is found
this.subs.add(
this.poller.start().subscribe(() => {
if (!this.currNamespace) {
return;
}
this.backend.getPVCs(this.currNamespace).subscribe(pvcs => {
if (!isEqual(this.rawData, pvcs)) {
this.rawData = pvcs;
// Update the frontend's state
this.processedData = this.parseIncomingData(pvcs);
this.poller.reset();
}
});
}),
);
// Reset the poller whenever the selected namespace changes
this.subs.add(
this.ns.getSelectedNamespace().subscribe(ns => {
this.currNamespace = ns;
this.pvcsWaitingViewer = new Set<string>();
this.poller.reset();
}),
);
}
public reactToAction(a: ActionEvent) {
switch (a.action) {
case 'newResourceButton': // TODO: could also use enums here
this.newResourceClicked();
break;
case 'delete':
this.deleteVolumeClicked(a.data);
break;
}
}
// Functions for handling the action events
public newResourceClicked() {
const ref = this.dialog.open(FormDefaultComponent, {
width: '600px',
panelClass: 'form--dialog-padding',
});
ref.afterClosed().subscribe(res => {
if (res === DIALOG_RESP.ACCEPT) {
this.snackBar.open(
'Volume was submitted successfully.',
SnackType.Success,
2000,
);
this.poller.reset();
}
});
}
public deleteVolumeClicked(pvc: PVCProcessedObject) {
const deleteDialogConfig: DialogConfig = {
title: `Are you sure you want to delete this volume? ${pvc.name}`,
message: 'Warning: All data in this volume will be lost.',
accept: 'DELETE',
confirmColor: 'warn',
cancel: 'CANCEL',
error: '',
applying: 'DELETING',
width: '600px',
};
const ref = this.confirmDialog.open(pvc.name, deleteDialogConfig);
const delSub = ref.componentInstance.applying$.subscribe(applying => {
if (!applying) {
return;
}
// Close the open dialog only if the DELETE request succeeded
this.backend.deletePVC(this.currNamespace, pvc.name).subscribe({
next: _ => {
this.poller.reset();
ref.close(DIALOG_RESP.ACCEPT);
},
error: err => {
// Simplify the error message
const errorMsg = err;
console.log(err);
deleteDialogConfig.error = errorMsg;
ref.componentInstance.applying$.next(false);
},
});
// DELETE request has succeeded
ref.afterClosed().subscribe(res => {
delSub.unsubscribe();
if (res !== DIALOG_RESP.ACCEPT) {
return;
}
pvc.status.phase = STATUS_TYPE.TERMINATING;
pvc.status.message = 'Preparing to delete the Volume...';
pvc.deleteAction = STATUS_TYPE.UNAVAILABLE;
this.pvcsWaitingViewer.delete(pvc.name);
});
});
}
// Utility funcs
public parseIncomingData(pvcs: PVCResponseObject[]): PVCProcessedObject[] {
const pvcsCopy = JSON.parse(JSON.stringify(pvcs)) as PVCProcessedObject[];
for (const pvc of pvcsCopy) {
pvc.deleteAction = this.parseDeletionActionStatus(pvc);
pvc.ageValue = pvc.age.uptime;
pvc.ageTooltip = pvc.age.timestamp;
}
return pvcsCopy;
}
public parseDeletionActionStatus(pvc: PVCProcessedObject) {
if (pvc.status.phase !== STATUS_TYPE.TERMINATING) {
return STATUS_TYPE.READY;
}
return STATUS_TYPE.TERMINATING;
}
public pvcTrackByFn(index: number, pvc: PVCProcessedObject) {
return `${pvc.name}/${pvc.namespace}/${pvc.capacity}`;
}
}

View File

@ -0,0 +1,30 @@
import { ActionListValue, ActionIconValue, TableColumn, TableConfig } from 'kubeflow';
import { tableConfig } from '../config';
const actionsCol: TableColumn = {
matHeaderCellDef: '',
matColumnDef: 'actions',
value: new ActionListValue([
new ActionIconValue({
name: 'edit',
tooltip: 'Browse',
color: 'primary',
field: 'editAction',
iconInit: 'material:folder',
iconReady: 'custom:folderSearch',
}),
new ActionIconValue({
name: 'delete',
tooltip: 'Delete Volume',
color: 'warn',
field: 'deleteAction',
iconReady: 'material:delete',
}),
]),
};
export const rokConfig: TableConfig = {
title: tableConfig.title,
newButtonText: tableConfig.newButtonText,
columns: tableConfig.columns.concat([actionsCol]),
};

View File

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

View File

@ -0,0 +1,163 @@
import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material';
import {
NamespaceService,
ConfirmDialogService,
DIALOG_RESP,
SnackBarService,
SnackType,
RokService,
ActionEvent,
STATUS_TYPE,
} from 'kubeflow';
import { environment } from '@app/environment';
import { VWABackendService } from 'src/app/services/backend.service';
import { IndexDefaultComponent } from '../index-default/index-default.component';
import { FormRokComponent } from '../../form/form-rok/form-rok.component';
import { rokConfig } from './config';
import { PVCProcessedObjectRok, PVCResponseObjectRok } from 'src/app/types';
@Component({
selector: 'app-index-rok',
templateUrl: '../index-default/index-default.component.html',
styleUrls: ['../index-default/index-default.component.scss'],
})
export class IndexRokComponent extends IndexDefaultComponent implements OnInit {
config = rokConfig;
constructor(
public ns: NamespaceService,
public confirmDialog: ConfirmDialogService,
public backend: VWABackendService,
public dialog: MatDialog,
public snackBar: SnackBarService,
public rok: RokService,
) {
super(ns, confirmDialog, backend, dialog, snackBar);
}
ngOnInit() {
super.ngOnInit();
this.rok.initCSRF();
}
// Functions for handling the action events
public newResourceClicked() {
const ref = this.dialog.open(FormRokComponent, {
width: '600px',
panelClass: 'form--dialog-padding',
});
ref.afterClosed().subscribe(res => {
if (res === DIALOG_RESP.ACCEPT) {
this.snackBar.open(
'Volume was submitted successfully.',
SnackType.Success,
2000,
);
this.poller.reset();
}
});
}
public reactToAction(a: ActionEvent) {
super.reactToAction(a);
switch (a.action) {
case 'edit':
this.editClicked(a.data);
break;
}
}
public editClicked(pvc: PVCProcessedObjectRok) {
if (pvc.viewer === STATUS_TYPE.READY) {
this.openEditWindow(pvc);
return;
}
this.pvcsWaitingViewer.add(pvc.name);
pvc.editAction = this.parseViewerActionStatus(pvc);
this.backend.createViewer(this.currNamespace, pvc.name).subscribe({
next: res => {
this.poller.reset();
},
error: err => {
this.pvcsWaitingViewer.delete(pvc.name);
pvc.editAction = this.parseViewerActionStatus(pvc);
},
});
}
// Utility funcs
public parseIncomingData(
pvcs: PVCResponseObjectRok[],
): PVCProcessedObjectRok[] {
const parsedPVCs = [];
for (const pvc of super.parseIncomingData(pvcs)) {
const parsedPVC = pvc as PVCProcessedObjectRok;
parsedPVC.editAction = this.parseViewerActionStatus(parsedPVC);
parsedPVCs.push(parsedPVC);
}
return parsedPVCs;
}
public parseViewerActionStatus(pvc: PVCProcessedObjectRok): STATUS_TYPE {
// If the PVC is being created or there was an error, then
// don't allow the user to edit it
if (
pvc.status.phase === STATUS_TYPE.UNINITIALIZED ||
pvc.status.phase === STATUS_TYPE.WAITING ||
pvc.status.phase === STATUS_TYPE.WARNING ||
pvc.status.phase === STATUS_TYPE.TERMINATING ||
pvc.status.phase === STATUS_TYPE.ERROR
) {
return STATUS_TYPE.UNAVAILABLE;
}
// The PVC is either READY or UNAVAILABLE(WaitForFirstConsumer)
// If the user had clicked to view the files and the viewer just
// became ready, then open the edit window
if (
this.pvcsWaitingViewer.has(pvc.name) &&
pvc.viewer === STATUS_TYPE.READY
) {
this.pvcsWaitingViewer.delete(pvc.name);
this.openEditWindow(pvc);
return STATUS_TYPE.READY;
}
// If the user clicked to view the files and the viewer
// is stil uninitialized or unavailable, then show a spinner
if (
this.pvcsWaitingViewer.has(pvc.name) &&
(pvc.viewer === STATUS_TYPE.UNINITIALIZED ||
pvc.viewer === STATUS_TYPE.WAITING)
) {
return STATUS_TYPE.WAITING;
}
// If the user hasn't yet clicked to edit the pvc, then the viewer
// button should be enabled
if (
!this.pvcsWaitingViewer.has(pvc.name) &&
pvc.status.state === 'WaitForFirstConsumer'
) {
return STATUS_TYPE.UNINITIALIZED;
}
return pvc.viewer;
}
public openEditWindow(pvc: PVCProcessedObjectRok) {
const url =
this.env.viewerUrl + `/volume/browser/${pvc.namespace}/${pvc.name}/`;
window.open(url, `${pvc.name}: Edit file contents`, 'height=600,width=800');
}
}

View File

@ -0,0 +1,2 @@
<app-index-default *ngIf="env.ui === 'default'"></app-index-default>
<app-index-rok *ngIf="env.ui === 'rok'"></app-index-rok>

View File

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

View File

@ -0,0 +1,13 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '@app/environment';
@Component({
selector: 'app-index',
templateUrl: './index.component.html',
styleUrls: ['./index.component.scss'],
})
export class IndexComponent {
env = environment;
constructor() {}
}

View File

@ -0,0 +1,15 @@
import { TestBed } from '@angular/core/testing';
import { VWABackendService } from './backend.service';
import { HttpClientModule } from '@angular/common/http';
describe('VWABackendService', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [HttpClientModule]
}));
it('should be created', () => {
const service: VWABackendService = TestBed.get(VWABackendService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,52 @@
import { Injectable } from '@angular/core';
import { BackendService, SnackBarService } from 'kubeflow';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { PVCResponseObject, VWABackendResponse, PVCPostObject } from '../types';
@Injectable({
providedIn: 'root',
})
export class VWABackendService extends BackendService {
constructor(public http: HttpClient, public snackBar: SnackBarService) {
super(http, snackBar);
}
public getPVCs(namespace: string): Observable<PVCResponseObject[]> {
const url = `api/namespaces/${namespace}/pvcs`;
return this.http.get<VWABackendResponse>(url).pipe(
catchError(error => this.handleError(error)),
map((resp: VWABackendResponse) => {
return resp.pvcs;
}),
);
}
// POST
public createViewer(namespace: string, viewer: string) {
const url = `api/namespaces/${namespace}/viewers`;
return this.http
.post<VWABackendResponse>(url, { name: viewer })
.pipe(catchError(error => this.handleError(error)));
}
public createPVC(namespace: string, pvc: PVCPostObject) {
const url = `api/namespaces/${namespace}/pvcs`;
return this.http
.post<VWABackendResponse>(url, pvc)
.pipe(catchError(error => this.handleError(error)));
}
// DELETE
public deletePVC(namespace: string, pvc: string) {
const url = `api/namespaces/${namespace}/pvcs/${pvc}`;
return this.http
.delete<VWABackendResponse>(url)
.pipe(catchError(error => this.handleError(error, false)));
}
}

View File

@ -0,0 +1,34 @@
import { Status, BackendResponse } from 'kubeflow';
export interface VWABackendResponse extends BackendResponse {
pvcs?: PVCResponseObject[];
}
export interface PVCResponseObject {
age: {
uptime: string;
timestamp: string;
};
capacity: string;
class: string;
modes: string[];
name: string;
namespace: string;
status: Status;
}
export interface PVCProcessedObject extends PVCResponseObject {
deleteAction?: string;
editAction?: string;
ageValue?: string;
ageTooltip?: string;
}
export interface PVCPostObject {
name: string;
type: string;
size: string | number;
class: string;
mode: string;
snapshot: string;
}

View File

@ -0,0 +1,2 @@
export * from './backend';
export * from './rok';

View File

@ -0,0 +1,10 @@
import { PVCResponseObject, PVCProcessedObject } from './backend';
import { STATUS_TYPE } from 'kubeflow';
export interface PVCResponseObjectRok extends PVCResponseObject {
viewer: STATUS_TYPE;
}
export interface PVCProcessedObjectRok
extends PVCResponseObjectRok,
PVCProcessedObject {}

View File

@ -0,0 +1,6 @@
export const environment = {
production: true,
viewerUrl: '',
resource: 'pvcviewers',
ui: 'default',
};

View File

@ -0,0 +1,6 @@
export const environment = {
production: true,
viewerUrl: '',
resource: 'pvcviewers',
ui: 'rok',
};

View File

@ -0,0 +1,19 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
viewerUrl: '//localhost:8081',
resource: 'pvcviewers',
ui: 'rok',
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@ -0,0 +1,19 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
viewerUrl: '//localhost:8081',
resource: 'pvcviewers',
ui: 'default',
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Frontend</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"
rel="stylesheet"
/>
<script defer src="/dashboard_lib.bundle.js"></script>
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@ -0,0 +1,9 @@
body {
--accent-color: #007dfc;
--accent-color-light: #69abff;
--accent-color-dark: #0052c8;
--kubeflow-color: #003c75;
--primary-background-color: #2196f3;
--sidebar-color: #f8fafb;
--border-color: #f4f4f6;
}

View File

@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));

View File

@ -0,0 +1,63 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@ -0,0 +1,2 @@
/* You can add global styles to this file, and also import other style files */
@import '~kubeflow/styles.scss';

View File

@ -0,0 +1,20 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
declare const require: any;
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.ts"
],
"exclude": [
"src/test.ts",
"src/**/*.spec.ts"
]
}

View File

@ -0,0 +1,25 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"module": "esnext",
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"paths": {
"@shared/*": ["../../angular-frontend-shared/*"],
"@app/environment": ["src/environments/environment"]
},
"typeRoots": ["node_modules/@types"],
"lib": ["es2018", "dom"]
},
"angularCompilerOptions": {
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true
}
}

View File

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine",
"node"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,57 @@
{
"extends": "tslint:recommended",
"rules": {
"array-type": false,
"arrow-parens": false,
"deprecation": {
"severity": "warning"
},
"component-class-suffix": true,
"contextual-lifecycle": true,
"directive-class-suffix": true,
"directive-selector": [true, "attribute", "app", "camelCase"],
"component-selector": [true, "element", "app", "kebab-case"],
"import-blacklist": [true, "rxjs/Rx"],
"interface-name": false,
"max-classes-per-file": false,
"max-line-length": [true, 140],
"member-access": false,
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-consecutive-blank-lines": false,
"no-console": [true, "debug", "info", "time", "timeEnd", "trace"],
"no-empty": false,
"no-inferrable-types": [true, "ignore-params"],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-var-requires": false,
"object-literal-key-quotes": [true, "as-needed"],
"object-literal-sort-keys": false,
"ordered-imports": false,
"quotemark": [true, "single"],
"trailing-comma": false,
"no-conflicting-lifecycle": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-inputs-metadata-property": true,
"no-output-native": true,
"no-output-on-prefix": true,
"no-output-rename": true,
"no-outputs-metadata-property": true,
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true
},
"rulesDirectory": ["codelyzer"]
}