Move commits from `kubeflow/kubeflow`
This commit is contained in:
commit
290935a205
|
@ -0,0 +1,3 @@
|
|||
module github.com/kubeflow/kubeflow/components/common
|
||||
|
||||
go 1.12
|
|
@ -0,0 +1,219 @@
|
|||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrs "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
// Deployment reconciles a k8s deployment object.
|
||||
func Deployment(ctx context.Context, r client.Client, deployment *appsv1.Deployment, log logr.Logger) error {
|
||||
foundDeployment := &appsv1.Deployment{}
|
||||
justCreated := false
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: deployment.Name, Namespace: deployment.Namespace}, foundDeployment); err != nil {
|
||||
if apierrs.IsNotFound(err) {
|
||||
log.Info("Creating Deployment", "namespace", deployment.Namespace, "name", deployment.Name)
|
||||
if err := r.Create(ctx, deployment); err != nil {
|
||||
log.Error(err, "unable to create deployment")
|
||||
return err
|
||||
}
|
||||
justCreated = true
|
||||
} else {
|
||||
log.Error(err, "error getting deployment")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !justCreated && CopyDeploymentSetFields(deployment, foundDeployment) {
|
||||
log.Info("Updating Deployment", "namespace", deployment.Namespace, "name", deployment.Name)
|
||||
if err := r.Update(ctx, foundDeployment); err != nil {
|
||||
log.Error(err, "unable to update deployment")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service reconciles a k8s service object.
|
||||
func Service(ctx context.Context, r client.Client, service *corev1.Service, log logr.Logger) error {
|
||||
foundService := &corev1.Service{}
|
||||
justCreated := false
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: service.Name, Namespace: service.Namespace}, foundService); err != nil {
|
||||
if apierrs.IsNotFound(err) {
|
||||
log.Info("Creating Service", "namespace", service.Namespace, "name", service.Name)
|
||||
if err = r.Create(ctx, service); err != nil {
|
||||
log.Error(err, "unable to create service")
|
||||
return err
|
||||
}
|
||||
justCreated = true
|
||||
} else {
|
||||
log.Error(err, "error getting service")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !justCreated && CopyServiceFields(service, foundService) {
|
||||
log.Info("Updating Service\n", "namespace", service.Namespace, "name", service.Name)
|
||||
if err := r.Update(ctx, foundService); err != nil {
|
||||
log.Error(err, "unable to update Service")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VirtualService reconciles an Istio virtual service object.
|
||||
func VirtualService(ctx context.Context, r client.Client, virtualServiceName, namespace string, virtualservice *unstructured.Unstructured, log logr.Logger) error {
|
||||
foundVirtualService := &unstructured.Unstructured{}
|
||||
foundVirtualService.SetAPIVersion("networking.istio.io/v1alpha3")
|
||||
foundVirtualService.SetKind("VirtualService")
|
||||
justCreated := false
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: virtualServiceName, Namespace: namespace}, foundVirtualService); err != nil {
|
||||
if apierrs.IsNotFound(err) {
|
||||
log.Info("Creating virtual service", "namespace", namespace, "name", virtualServiceName)
|
||||
if err := r.Create(ctx, virtualservice); err != nil {
|
||||
log.Error(err, "unable to create virtual service")
|
||||
return err
|
||||
}
|
||||
justCreated = true
|
||||
} else {
|
||||
log.Error(err, "error getting virtual service")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !justCreated && CopyVirtualService(virtualservice, foundVirtualService) {
|
||||
log.Info("Updating virtual service", "namespace", namespace, "name", virtualServiceName)
|
||||
if err := r.Update(ctx, foundVirtualService); err != nil {
|
||||
log.Error(err, "unable to update virtual service")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reference: https://github.com/pwittrock/kubebuilder-workshop/blob/master/pkg/util/util.go
|
||||
|
||||
// CopyStatefulSetFields copies the owned fields from one StatefulSet to another
|
||||
// Returns true if the fields copied from don't match to.
|
||||
func CopyStatefulSetFields(from, to *appsv1.StatefulSet) bool {
|
||||
requireUpdate := false
|
||||
for k, v := range to.Labels {
|
||||
if from.Labels[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Labels = from.Labels
|
||||
|
||||
for k, v := range to.Annotations {
|
||||
if from.Annotations[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Annotations = from.Annotations
|
||||
|
||||
if *from.Spec.Replicas != *to.Spec.Replicas {
|
||||
*to.Spec.Replicas = *from.Spec.Replicas
|
||||
requireUpdate = true
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(to.Spec.Template.Spec, from.Spec.Template.Spec) {
|
||||
requireUpdate = true
|
||||
}
|
||||
to.Spec.Template.Spec = from.Spec.Template.Spec
|
||||
|
||||
return requireUpdate
|
||||
}
|
||||
|
||||
func CopyDeploymentSetFields(from, to *appsv1.Deployment) bool {
|
||||
requireUpdate := false
|
||||
for k, v := range to.Labels {
|
||||
if from.Labels[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Labels = from.Labels
|
||||
|
||||
for k, v := range to.Annotations {
|
||||
if from.Annotations[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Annotations = from.Annotations
|
||||
|
||||
if from.Spec.Replicas != to.Spec.Replicas {
|
||||
to.Spec.Replicas = from.Spec.Replicas
|
||||
requireUpdate = true
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(to.Spec.Template.Spec, from.Spec.Template.Spec) {
|
||||
requireUpdate = true
|
||||
}
|
||||
to.Spec.Template.Spec = from.Spec.Template.Spec
|
||||
|
||||
return requireUpdate
|
||||
}
|
||||
|
||||
// CopyServiceFields copies the owned fields from one Service to another
|
||||
func CopyServiceFields(from, to *corev1.Service) bool {
|
||||
requireUpdate := false
|
||||
for k, v := range to.Labels {
|
||||
if from.Labels[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Labels = from.Labels
|
||||
|
||||
for k, v := range to.Annotations {
|
||||
if from.Annotations[k] != v {
|
||||
requireUpdate = true
|
||||
}
|
||||
}
|
||||
to.Annotations = from.Annotations
|
||||
|
||||
// Don't copy the entire Spec, because we can't overwrite the clusterIp field
|
||||
|
||||
if !reflect.DeepEqual(to.Spec.Selector, from.Spec.Selector) {
|
||||
requireUpdate = true
|
||||
}
|
||||
to.Spec.Selector = from.Spec.Selector
|
||||
|
||||
if !reflect.DeepEqual(to.Spec.Ports, from.Spec.Ports) {
|
||||
requireUpdate = true
|
||||
}
|
||||
to.Spec.Ports = from.Spec.Ports
|
||||
|
||||
return requireUpdate
|
||||
}
|
||||
|
||||
// Copy configuration related fields to another instance and returns true if there
|
||||
// is a diff and thus needs to update.
|
||||
func CopyVirtualService(from, to *unstructured.Unstructured) bool {
|
||||
fromSpec, found, err := unstructured.NestedMap(from.Object, "spec")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
toSpec, found, err := unstructured.NestedMap(to.Object, "spec")
|
||||
if !found || err != nil {
|
||||
unstructured.SetNestedMap(to.Object, fromSpec, "spec")
|
||||
return true
|
||||
}
|
||||
|
||||
requiresUpdate := !reflect.DeepEqual(fromSpec, toSpec)
|
||||
if requiresUpdate {
|
||||
unstructured.SetNestedMap(to.Object, fromSpec, "spec")
|
||||
}
|
||||
return requiresUpdate
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
**/node_modules/*
|
||||
**/__pycache__/*
|
|
@ -0,0 +1 @@
|
|||
**/web-apps-dev
|
|
@ -0,0 +1,6 @@
|
|||
approvers:
|
||||
- kimwnasptd
|
||||
- thesuperzapper
|
||||
emeritus_approvers:
|
||||
- elikatsis
|
||||
- StefanoFioravanzo
|
|
@ -0,0 +1,170 @@
|
|||
# Common code for CRUD web apps
|
||||
|
||||
Since our CRUD web apps like the Jupyter, Tensorboards and Volumes UIs are similarly build with Angular and Python/Flask we should factor the common code in to modules and libraries.
|
||||
|
||||
This directory will contain:
|
||||
|
||||
1. A Python package with a base backend. Each one of the mentioned apps are supposed to extend this backend.
|
||||
2. An Angular library that will contain the common frontend code that these apps will be sharing
|
||||
|
||||
## Backend
|
||||
|
||||
The backend will be exposing a base backend which will be taking care of:
|
||||
|
||||
* Serving the Single Page Application
|
||||
* Adding liveness/readiness probes
|
||||
* Authentication based on the `kubeflow-userid` header
|
||||
* Authorization using SubjectAccessReviews
|
||||
* Uniform logging
|
||||
* Exceptions handling
|
||||
* Common helper functions for dates, yaml file parsing etc.
|
||||
* Providing Prometheus metrics
|
||||
|
||||
### Supported ENV Vars
|
||||
|
||||
The following is a list of ENV var that can be set for any web app that is using this base app.
|
||||
This is list is incomplete, we will be adding more variables in the future.
|
||||
|
||||
| ENV Var | Description |
|
||||
| - | - |
|
||||
| CSRF_SAMESITE | Controls the [SameSite value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#SameSite) of the CSRF cookie |
|
||||
| METRICS | Enable the exporting of Prometheus metrics on `/metrics` path |
|
||||
|
||||
### How to use
|
||||
|
||||
In order to use this code during the development process one could use the `-e` [flag](https://pip.pypa.io/en/stable/reference/pip_install/#install-editable) with `pip install`. For example:
|
||||
|
||||
```bash
|
||||
# I'm currently in /components/crud-web-apps/volumes/backend
|
||||
cd ../../common/backend && pip install -e .
|
||||
```
|
||||
|
||||
This will install all the dependencies of the package and you will now be able to include code from `kubeflow.kubeflow.crud_backend` in you current Python environment.
|
||||
|
||||
In order to build a Docker image and use this code you coud build a wheel and then install it:
|
||||
|
||||
```dockerfile
|
||||
### Docker
|
||||
FROM python:3.7 AS backend-kubeflow-wheel
|
||||
|
||||
WORKDIR /src
|
||||
COPY ./components/crud-web-apps/common/backend .
|
||||
|
||||
RUN python3 setup.py bdist_wheel
|
||||
|
||||
...
|
||||
# Web App
|
||||
FROM python:3.7
|
||||
|
||||
WORKDIR /package
|
||||
COPY --from=backend-kubeflow-wheel /src .
|
||||
RUN pip3 install .
|
||||
...
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
The following metrics are exported:
|
||||
|
||||
flask_http_request_duration_seconds (Histogram)
|
||||
flask_http_request_total (Counter)
|
||||
flask_http_request_exceptions_total (Counter)
|
||||
flask_exporter_info (Gauge)
|
||||
|
||||
For more information visit the [prometheus_flask_exporter](https://github.com/rycus86/prometheus_flask_exporter).
|
||||
|
||||
## Frontend
|
||||
|
||||
The common Angular library contains common code for:
|
||||
|
||||
* Communicating with the Central Dashboard to handle the Namespace selection
|
||||
* Making http calls and handing their errors
|
||||
* Surfacing errors and warnings
|
||||
* Polling mechanisms
|
||||
* Universal styling accross the different web apps
|
||||
* Handling forms
|
||||
|
||||
### How to use
|
||||
|
||||
```bash
|
||||
# build the common library
|
||||
cd common/frontend/kubeflow-common-lib
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
# for development link the created module to the app
|
||||
cd dist/kubeflow
|
||||
npm link
|
||||
|
||||
# navigate to the corresponding app's frontend
|
||||
cd crud-web-apps/volumes/frontend
|
||||
npm i
|
||||
npm link kubeflow
|
||||
```
|
||||
|
||||
### Common errors
|
||||
|
||||
```
|
||||
NullInjectorError: StaticInjectorError(AppModule)[ApplicationRef -> NgZone]:
|
||||
StaticInjectorError(Platform: core)[ApplicationRef -> NgZone]:
|
||||
NullInjectorError: No provider for NgZone!
|
||||
```
|
||||
|
||||
You also need to add `"preserveSymlinks": true` to the app's frontend `angular.json` at `projects.frontend.architect.build.options`.
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
# --- Build the frontend kubeflow library ---
|
||||
FROM node:16 as frontend-kubeflow-lib
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY ./components/crud-web-apps/common/frontend/kubeflow-common-lib/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY ./components/crud-web-apps/common/frontend/kubeflow-common-lib/ .
|
||||
RUN npm run build
|
||||
|
||||
...
|
||||
# --- Build the frontend ---
|
||||
FROM node:16 as frontend
|
||||
RUN npm install -g @angular/cli
|
||||
|
||||
WORKDIR /src
|
||||
COPY ./components/crud-web-apps/volumes/frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY --from=frontend-kubeflow-lib /src/dist/kubeflow/ ./node_modules/kubeflow/
|
||||
|
||||
COPY ./components/crud-web-apps/volumes/frontend/ .
|
||||
|
||||
RUN npm run build -- --output-path=./dist/default --configuration=production
|
||||
```
|
||||
|
||||
### Internationalization
|
||||
|
||||
Internationalization was implemented using [ngx-translate](https://github.com/ngx-translate/core).
|
||||
|
||||
This is based on the browser's language. If the browser detects a language that is not implemented in the application, it will default to English.
|
||||
|
||||
The i18n asset files are located under `frontend/src/assets/i18n` of each application (jupyter, volumes and tensorboard). One file is needed per language. The common project is duplicated in every asset.
|
||||
|
||||
The translation asset files are set in the `app.module.ts`, which should not be needed to modify.
|
||||
The translation default language is set in the `app.component.ts`.
|
||||
|
||||
For each language added, `app.component.ts` will need to be updated.
|
||||
|
||||
**When a language is added:**
|
||||
|
||||
- Copy the en.json file and rename is to the language you want to add. As it currently is, the culture should not be included.
|
||||
- Change the values to the translated ones
|
||||
|
||||
**When a translation is added or modified:**
|
||||
|
||||
- Choose an appropriate key
|
||||
- Make sure to add the key in every language file
|
||||
- If text is added/modified in the Common Project, it needs to be added/modified in the other applications as well.
|
||||
|
||||
**Testing**
|
||||
|
||||
To test the i18n works as expected, simply change your browser's language to whichever language you want to test.
|
|
@ -0,0 +1,3 @@
|
|||
kubeflow.egg-info
|
||||
dist
|
||||
build
|
|
@ -0,0 +1,39 @@
|
|||
import logging
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .authn import bp as authn_bp
|
||||
from .config import BackendMode
|
||||
from .csrf import bp as csrf_bp
|
||||
from .errors import bp as errors_bp
|
||||
from .metrics import enable_metrics
|
||||
from .probes import bp as probes_bp
|
||||
from .routes import bp as base_routes_bp
|
||||
from .serving import bp as serving_bp
|
||||
|
||||
LOG_FORMAT = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
||||
|
||||
|
||||
def create_app(name, static_folder, config):
|
||||
logging.basicConfig(format=LOG_FORMAT, level=config.LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(name, static_folder=static_folder)
|
||||
app.config.from_object(config)
|
||||
|
||||
if (config.ENV == BackendMode.DEVELOPMENT.value
|
||||
or config.ENV == BackendMode.DEVELOPMENT_FULL.value): # noqa: W503
|
||||
log.warn("RUNNING IN DEVELOPMENT MODE")
|
||||
|
||||
# Register all the blueprints
|
||||
app.register_blueprint(authn_bp)
|
||||
app.register_blueprint(errors_bp)
|
||||
app.register_blueprint(csrf_bp)
|
||||
app.register_blueprint(probes_bp)
|
||||
app.register_blueprint(serving_bp)
|
||||
app.register_blueprint(base_routes_bp)
|
||||
|
||||
if config.METRICS:
|
||||
enable_metrics(app)
|
||||
|
||||
return app
|
|
@ -0,0 +1,11 @@
|
|||
from .apis import * # noqa F401, F403
|
||||
from .custom_resource import * # noqa F401, F403
|
||||
from .namespace import * # noqa F401, F403
|
||||
from .node import * # noqa F401, F403
|
||||
from .notebook import * # noqa F401, F403
|
||||
from .pod import * # noqa F401, F403
|
||||
from .poddefault import * # noqa F401, F403
|
||||
from .pvc import * # noqa F401, F403
|
||||
from .secret import * # noqa F401, F403
|
||||
from .storageclass import * # noqa F401, F403
|
||||
from .utils import * # noqa F401, F403
|
|
@ -0,0 +1,12 @@
|
|||
from kubernetes import client, config
|
||||
from kubernetes.config import ConfigException
|
||||
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
except ConfigException:
|
||||
config.load_kube_config()
|
||||
|
||||
# Create the Apis
|
||||
v1_core = client.CoreV1Api()
|
||||
custom_api = client.CustomObjectsApi()
|
||||
storage_api = client.StorageV1Api()
|
|
@ -0,0 +1,29 @@
|
|||
from .. import authz
|
||||
from . import custom_api
|
||||
|
||||
|
||||
def create_custom_rsrc(group, version, kind, data, namespace):
|
||||
authz.ensure_authorized("create", group, version, kind, namespace)
|
||||
return custom_api.create_namespaced_custom_object(group, version,
|
||||
namespace, kind, data)
|
||||
|
||||
|
||||
def delete_custom_rsrc(group, version, kind, name, namespace,
|
||||
policy="Foreground"):
|
||||
authz.ensure_authorized("delete", group, version, kind, namespace)
|
||||
return custom_api.delete_namespaced_custom_object(
|
||||
group, version, namespace, kind, name, propagation_policy=policy
|
||||
)
|
||||
|
||||
|
||||
def list_custom_rsrc(group, version, kind, namespace):
|
||||
authz.ensure_authorized("list", group, version, kind, namespace)
|
||||
return custom_api.list_namespaced_custom_object(group, version, namespace,
|
||||
kind)
|
||||
|
||||
|
||||
def get_custom_rsrc(group, version, kind, namespace, name):
|
||||
authz.ensure_authorized("get", group, version, kind, namespace)
|
||||
|
||||
return custom_api.get_namespaced_custom_object(group, version, namespace,
|
||||
kind, name)
|
|
@ -0,0 +1,12 @@
|
|||
from .. import authz
|
||||
from . import v1_core
|
||||
|
||||
|
||||
def list_events(namespace, field_selector):
|
||||
authz.ensure_authorized(
|
||||
"list", "", "v1", "events", namespace
|
||||
)
|
||||
|
||||
return v1_core.list_namespaced_event(
|
||||
namespace=namespace, field_selector=field_selector
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
from .. import authz
|
||||
from . import v1_core
|
||||
|
||||
|
||||
@authz.needs_authorization("list", "core", "v1", "namespaces")
|
||||
def list_namespaces():
|
||||
return v1_core.list_namespace()
|
|
@ -0,0 +1,5 @@
|
|||
from . import v1_core
|
||||
|
||||
|
||||
def list_nodes():
|
||||
return v1_core.list_node()
|
|
@ -0,0 +1,61 @@
|
|||
from .. import authz
|
||||
from . import custom_api, events, utils
|
||||
|
||||
|
||||
def get_notebook(notebook, namespace):
|
||||
authz.ensure_authorized(
|
||||
"get", "kubeflow.org", "v1beta1", "notebooks", namespace
|
||||
)
|
||||
return custom_api.get_namespaced_custom_object(
|
||||
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook
|
||||
)
|
||||
|
||||
|
||||
def create_notebook(notebook, namespace, dry_run=False):
|
||||
authz.ensure_authorized(
|
||||
"create", "kubeflow.org", "v1beta1", "notebooks", namespace
|
||||
)
|
||||
|
||||
return custom_api.create_namespaced_custom_object(
|
||||
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook,
|
||||
dry_run="All" if dry_run else None)
|
||||
|
||||
|
||||
def list_notebooks(namespace):
|
||||
authz.ensure_authorized(
|
||||
"list", "kubeflow.org", "v1beta1", "notebooks", namespace
|
||||
)
|
||||
return custom_api.list_namespaced_custom_object(
|
||||
"kubeflow.org", "v1beta1", namespace, "notebooks"
|
||||
)
|
||||
|
||||
|
||||
def delete_notebook(notebook, namespace):
|
||||
authz.ensure_authorized(
|
||||
"delete", "kubeflow.org", "v1beta1", "notebooks", namespace
|
||||
)
|
||||
return custom_api.delete_namespaced_custom_object(
|
||||
group="kubeflow.org",
|
||||
version="v1beta1",
|
||||
namespace=namespace,
|
||||
plural="notebooks",
|
||||
name=notebook,
|
||||
propagation_policy="Foreground",
|
||||
)
|
||||
|
||||
|
||||
def patch_notebook(notebook, namespace, body):
|
||||
authz.ensure_authorized(
|
||||
"patch", "kubeflow.org", "v1beta1", "notebooks", namespace
|
||||
)
|
||||
|
||||
return custom_api.patch_namespaced_custom_object(
|
||||
"kubeflow.org", "v1beta1", namespace, "notebooks", notebook, body
|
||||
)
|
||||
|
||||
|
||||
def list_notebook_events(notebook, namespace):
|
||||
|
||||
field_selector = utils.events_field_selector("Notebook", notebook)
|
||||
|
||||
return events.list_events(namespace, field_selector)
|
|
@ -0,0 +1,20 @@
|
|||
from .. import authz
|
||||
from . import v1_core
|
||||
|
||||
|
||||
def list_pods(namespace, auth=True, label_selector=None):
|
||||
if auth:
|
||||
authz.ensure_authorized("list", "", "v1", "pods", namespace)
|
||||
|
||||
return v1_core.list_namespaced_pod(
|
||||
namespace=namespace,
|
||||
label_selector=label_selector)
|
||||
|
||||
|
||||
def get_pod_logs(namespace, pod, container, auth=True):
|
||||
if auth:
|
||||
authz.ensure_authorized("get", "", "v1", "pods", namespace, "log")
|
||||
|
||||
return v1_core.read_namespaced_pod_log(
|
||||
namespace=namespace, name=pod, container=container
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
from .. import authz
|
||||
from . import custom_api
|
||||
|
||||
|
||||
def list_poddefaults(namespace):
|
||||
authz.ensure_authorized("list", "kubeflow.org", "v1alpha1", "poddefaults",
|
||||
namespace)
|
||||
return custom_api.list_namespaced_custom_object("kubeflow.org", "v1alpha1",
|
||||
namespace, "poddefaults")
|
|
@ -0,0 +1,49 @@
|
|||
from .. import authz
|
||||
from . import v1_core, utils, events
|
||||
|
||||
|
||||
def create_pvc(pvc, namespace, dry_run=False):
|
||||
authz.ensure_authorized(
|
||||
"create", "", "v1", "persistentvolumeclaims", namespace
|
||||
)
|
||||
|
||||
return v1_core.create_namespaced_persistent_volume_claim(
|
||||
namespace, pvc, dry_run="All" if dry_run else None)
|
||||
|
||||
|
||||
def delete_pvc(pvc, namespace):
|
||||
authz.ensure_authorized(
|
||||
"delete", "", "v1", "persistentvolumeclaims", namespace
|
||||
)
|
||||
return v1_core.delete_namespaced_persistent_volume_claim(pvc, namespace)
|
||||
|
||||
|
||||
def list_pvcs(namespace):
|
||||
authz.ensure_authorized(
|
||||
"list", "", "v1", "persistentvolumeclaims", namespace
|
||||
)
|
||||
return v1_core.list_namespaced_persistent_volume_claim(namespace)
|
||||
|
||||
|
||||
def get_pvc(pvc, namespace):
|
||||
authz.ensure_authorized(
|
||||
"get", "", "v1", "persistentvolumeclaims", namespace
|
||||
)
|
||||
return v1_core.read_namespaced_persistent_volume_claim(pvc, namespace)
|
||||
|
||||
|
||||
def list_pvc_events(namespace, pvc_name):
|
||||
|
||||
field_selector = utils.events_field_selector(
|
||||
"PersistentVolumeClaim", pvc_name)
|
||||
|
||||
return events.list_events(namespace, field_selector)
|
||||
|
||||
|
||||
def patch_pvc(name, namespace, pvc, auth=True):
|
||||
if auth:
|
||||
authz.ensure_authorized("patch", "", "v1", "persistentvolumeclaims",
|
||||
namespace)
|
||||
|
||||
return v1_core.patch_namespaced_persistent_volume_claim(name, namespace,
|
||||
pvc)
|
|
@ -0,0 +1,16 @@
|
|||
from .. import authz
|
||||
from . import v1_core
|
||||
|
||||
|
||||
def get_secret(namespace, name, auth=True):
|
||||
if auth:
|
||||
authz.ensure_authorized("get", "", "v1", "secrets", namespace)
|
||||
|
||||
return v1_core.read_namespaced_secret(name, namespace)
|
||||
|
||||
|
||||
def create_secret(namespace, secret, auth=True):
|
||||
if auth:
|
||||
authz.ensure_authorized("create", "", "v1", "secrets", namespace)
|
||||
|
||||
return v1_core.create_namespaced_secret(namespace, secret)
|
|
@ -0,0 +1,11 @@
|
|||
from . import storage_api
|
||||
|
||||
|
||||
# @auth.needs_authorization("list", "storage.k8s.io", "v1", "storageclasses")
|
||||
# NOTE(kimwnasptd): This function is only used from the backend in order to
|
||||
# determine if a default StorageClass is set. Currently, the role aggregation
|
||||
# does not use a ClusterRoleBinding, thus we can't currently give this
|
||||
# permission to a user. The backend does not expose any endpoint that would
|
||||
# allow an unauthorized user to list the storage classes using this function.
|
||||
def list_storageclasses():
|
||||
return storage_api.list_storage_class()
|
|
@ -0,0 +1,48 @@
|
|||
from flask import jsonify
|
||||
from kubernetes import client
|
||||
|
||||
from .. import authn
|
||||
|
||||
|
||||
def success_response(data_field=None, data=None):
|
||||
user = authn.get_username()
|
||||
resp = {"status": 200, "success": True, "user": user}
|
||||
if data_field is None and data is None:
|
||||
return jsonify(resp)
|
||||
|
||||
resp[data_field] = data
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
def failed_response(msg, error_code):
|
||||
user = authn.get_username()
|
||||
resp = {
|
||||
"success": False,
|
||||
"log": msg,
|
||||
"status": error_code,
|
||||
"user": user,
|
||||
}
|
||||
|
||||
return resp, error_code
|
||||
|
||||
|
||||
def events_field_selector(kind, name):
|
||||
return "involvedObject.kind=%s,involvedObject.name=%s" % (kind, name)
|
||||
|
||||
|
||||
def deserialize(json_obj, klass):
|
||||
"""Convert a JSON object to a lib class object.
|
||||
|
||||
json_obj: The JSON object to deserialize
|
||||
klass: The string name of the class i.e. V1Pod, V1Volume etc
|
||||
"""
|
||||
try:
|
||||
return client.ApiClient()._ApiClient__deserialize(json_obj, klass)
|
||||
except ValueError as e:
|
||||
raise ValueError("Failed to deserialize input into '%s': %s"
|
||||
% (klass, str(e)))
|
||||
|
||||
|
||||
def serialize(obj):
|
||||
"""Convert a K8s library object to JSON."""
|
||||
return client.ApiClient().sanitize_for_serialization(obj)
|
|
@ -0,0 +1,67 @@
|
|||
import logging
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from . import config, settings
|
||||
|
||||
bp = Blueprint("authn", __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_username():
|
||||
if settings.USER_HEADER not in request.headers:
|
||||
log.debug("User header not present!")
|
||||
username = None
|
||||
else:
|
||||
user = request.headers[settings.USER_HEADER]
|
||||
username = user.replace(settings.USER_PREFIX, "")
|
||||
log.debug("User: '%s' | Headers: '%s' '%s'",
|
||||
username, settings.USER_HEADER, settings.USER_PREFIX)
|
||||
|
||||
return username
|
||||
|
||||
|
||||
def no_authentication(func):
|
||||
"""
|
||||
This decorator will be used to disable the default authentication check
|
||||
for the decorated endpoint.
|
||||
"""
|
||||
func.no_authentication = True
|
||||
return func
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def check_authentication():
|
||||
"""
|
||||
By default all the app's routes will be subject to authentication. If we
|
||||
want a function to not have authentication check then we can decorate it
|
||||
with the `no_authentication` decorator.
|
||||
"""
|
||||
if config.dev_mode_enabled():
|
||||
log.debug("Skipping authentication check in development mode")
|
||||
return
|
||||
|
||||
if settings.DISABLE_AUTH:
|
||||
log.info("APP_DISABLE_AUTH set to True. Skipping authentication check")
|
||||
return
|
||||
|
||||
# If a function was decorated with `no_authentication` then we will skip
|
||||
# the authn check
|
||||
if request.endpoint and getattr(
|
||||
current_app.view_functions[request.endpoint],
|
||||
"no_authentication",
|
||||
False,
|
||||
):
|
||||
# when no return value is specified the designated route function will
|
||||
# be called.
|
||||
return
|
||||
|
||||
user = get_username()
|
||||
if user is None:
|
||||
# Return an unauthenticated response and don't call the route's
|
||||
# assigned function.
|
||||
raise Unauthorized("No user detected.")
|
||||
else:
|
||||
log.info("Handling request for user: %s", user)
|
||||
return
|
|
@ -0,0 +1,132 @@
|
|||
import functools
|
||||
import logging
|
||||
|
||||
from kubernetes import client
|
||||
from kubernetes import config as k8s_config
|
||||
from kubernetes.client.rest import ApiException
|
||||
from kubernetes.config import ConfigException
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
|
||||
from . import authn, config, settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Load configuration inside the Pod
|
||||
k8s_config.load_incluster_config()
|
||||
except ConfigException:
|
||||
# Load configuration for testing
|
||||
k8s_config.load_kube_config()
|
||||
|
||||
# The API object for submitting SubjecAccessReviews
|
||||
authz_api = client.AuthorizationV1Api()
|
||||
|
||||
|
||||
def create_subject_access_review(user, verb, namespace, group, version,
|
||||
resource, subresource):
|
||||
"""
|
||||
Create the SubjecAccessReview object which we will use to determine if the
|
||||
user is authorized.
|
||||
"""
|
||||
return client.V1SubjectAccessReview(
|
||||
spec=client.V1SubjectAccessReviewSpec(
|
||||
user=user,
|
||||
resource_attributes=client.V1ResourceAttributes(
|
||||
group=group,
|
||||
namespace=namespace,
|
||||
verb=verb,
|
||||
resource=resource,
|
||||
version=version,
|
||||
subresource=subresource,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_authorized(user, verb, group, version, resource, namespace=None,
|
||||
subresource=None):
|
||||
"""
|
||||
Create a SubjectAccessReview to the K8s API to determine if the user is
|
||||
authorized to perform a specific verb on a resource.
|
||||
"""
|
||||
# Skip authz check if in dev mode
|
||||
if config.dev_mode_enabled():
|
||||
log.debug("Skipping authorization check in development mode")
|
||||
return True
|
||||
|
||||
# Skip authz check if admin explicitly requested it
|
||||
if settings.DISABLE_AUTH:
|
||||
log.info("APP_DISABLE_AUTH set to True. Skipping authorization check")
|
||||
return True
|
||||
|
||||
if user is None:
|
||||
log.warning("No user credentials were found! Make sure you have"
|
||||
" correctly set the USERID_HEADER in the Web App's"
|
||||
" deployment.")
|
||||
raise Unauthorized(description="No user credentials were found!")
|
||||
|
||||
sar = create_subject_access_review(user, verb, namespace, group, version,
|
||||
resource, subresource)
|
||||
try:
|
||||
obj = authz_api.create_subject_access_review(sar)
|
||||
except ApiException as e:
|
||||
log.error("Error submitting SubjecAccessReview: %s, %s", sar, e)
|
||||
raise e
|
||||
|
||||
if obj.status is not None:
|
||||
return obj.status.allowed
|
||||
else:
|
||||
log.error("SubjectAccessReview doesn't have status.")
|
||||
return False
|
||||
|
||||
|
||||
def generate_unauthorized_message(user, verb, group, version, resource,
|
||||
subresource=None, namespace=None):
|
||||
msg = "User '%s' is not authorized to %s" % (user, verb)
|
||||
|
||||
if group == "":
|
||||
msg += " %s/%s" % (version, resource)
|
||||
else:
|
||||
msg += " %s/%s/%s" % (group, version, resource)
|
||||
|
||||
if subresource is not None:
|
||||
msg += "/%s" % subresource
|
||||
|
||||
if namespace is not None:
|
||||
msg += " in namespace '%s'" % namespace
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def ensure_authorized(verb, group, version, resource, namespace=None,
|
||||
subresource=None):
|
||||
user = authn.get_username()
|
||||
if not is_authorized(user, verb, group, version, resource,
|
||||
namespace=namespace, subresource=subresource):
|
||||
|
||||
msg = generate_unauthorized_message(user, verb, group, version,
|
||||
resource, subresource=subresource,
|
||||
namespace=namespace)
|
||||
raise Forbidden(description=msg)
|
||||
|
||||
|
||||
def needs_authorization(verb, group, version, resource, namespace=None,
|
||||
subresource=None):
|
||||
"""
|
||||
This function will serve as a decorator. It will be used to make sure that
|
||||
the decorated function is authorized to perform the corresponding k8s api
|
||||
verb on a specific resource.
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
@functools.wraps(func)
|
||||
def runner(*args, **kwargs):
|
||||
# Run the decorated function only if the user is authorized
|
||||
ensure_authorized(verb, group, version, resource,
|
||||
namespace=namespace, subresource=subresource)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return runner
|
||||
|
||||
return wrapper
|
|
@ -0,0 +1,66 @@
|
|||
import enum
|
||||
import logging
|
||||
import os
|
||||
|
||||
from flask import current_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackendMode(enum.Enum):
|
||||
DEVELOPMENT = "dev"
|
||||
DEVELOPMENT_FULL = "development"
|
||||
PRODUCTION = "prod"
|
||||
PRODUCTION_FULL = "production"
|
||||
|
||||
|
||||
def dev_mode_enabled():
|
||||
env = current_app.config.get("ENV")
|
||||
return (env == BackendMode.DEVELOPMENT_FULL.value or # noqa: W504
|
||||
env == BackendMode.DEVELOPMENT.value)
|
||||
|
||||
|
||||
def get_config(mode):
|
||||
"""Return a config based on the selected mode."""
|
||||
config_classes = {
|
||||
BackendMode.DEVELOPMENT.value: DevConfig,
|
||||
BackendMode.DEVELOPMENT_FULL.value: DevConfig,
|
||||
BackendMode.PRODUCTION.value: ProdConfig,
|
||||
BackendMode.PRODUCTION_FULL.value: 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()
|
||||
|
||||
|
||||
class Config(object):
|
||||
ENV = "generic"
|
||||
DEBUG = False
|
||||
STATIC_DIR = "./static/"
|
||||
JSONIFY_PRETTYPRINT_REGULAR = True
|
||||
LOG_LEVEL = logging.INFO
|
||||
PREFIX = "/"
|
||||
METRICS: bool = True
|
||||
|
||||
def __init__(self):
|
||||
if os.environ.get("LOG_LEVEL_DEBUG", "false") == "true":
|
||||
self.LOG_LEVEL = logging.DEBUG
|
||||
|
||||
self.METRICS = bool(os.environ.get("METRICS", True))
|
||||
|
||||
|
||||
class DevConfig(Config):
|
||||
ENV = BackendMode.DEVELOPMENT_FULL.value
|
||||
DEBUG = True
|
||||
LOG_LEVEL = logging.DEBUG
|
||||
|
||||
def __init__(self):
|
||||
super()
|
||||
log.warn("RUNNING IN DEVELOPMENT MODE")
|
||||
|
||||
|
||||
class ProdConfig(Config):
|
||||
ENV = BackendMode.PRODUCTION_FULL.value
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Cross Site Request Forgery Blueprint.
|
||||
|
||||
This module provides a Flask blueprint that implements protection against
|
||||
request forgeries from other sites. Currently, it is only meant to be used with
|
||||
an AJAX frontend, not with server-side rendered forms.
|
||||
|
||||
The module implements the following protecting measures against CSRF:
|
||||
- Double Submit Cookie.
|
||||
- Custom HTTP Headers.
|
||||
- SameSite cookie attribute.
|
||||
|
||||
To elaborate, the `Double Submit Cookie` procedure looks like the following:
|
||||
1. Browser requests `index.html`, which contains the compiled Javascript.
|
||||
2. Backend sets the `CSRF_COOKIE` by calling `set_cookie`. If the cookie
|
||||
already exists, `set_cookie` overrides it with a new one. The cookie
|
||||
contains a random value.
|
||||
3. Frontend (`index.html`) is loaded and starts making requests to the backend.
|
||||
For every request, the frontend reads the `CSRF_COOKIE` value and adds a
|
||||
`CSRF_HEADER` with the same value.
|
||||
4. Backend checks that the value of `CSRF_COOKIE` matches the value of
|
||||
`CSRF_HEADER`. All endpoints are checked, except the index endpoint and
|
||||
endpoints with safe methods (GET, HEAD, OPTIONS, TRACE).
|
||||
|
||||
Custom Headers (`CSRF_HEADER`) provide an extra layer of protection, as
|
||||
cross-origin requests cannot include custom headers (assuming CORS is not
|
||||
misconfigured) because of the Same-Origin policy.
|
||||
|
||||
The SameSite cookie attribute provides another layer of protection, but may
|
||||
impede usability so it is configurable. This attribute controls whether a
|
||||
cookie is sent by the browser when a cross-site request is made. It defaults to
|
||||
"Strict".
|
||||
|
||||
References:
|
||||
- OWASP CSRF Mitigation:
|
||||
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html # noqa: E501
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from . import settings
|
||||
|
||||
bp = Blueprint("csrf", __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: We can't make these configurable until we have a way to pass settings
|
||||
# to the frontend in Kubeflow Web Apps (e.g., a `/settings` endpoint).
|
||||
CSRF_COOKIE = "XSRF-TOKEN"
|
||||
CSRF_HEADER = "X-" + CSRF_COOKIE
|
||||
SAMESITE_VALUES = ["Strict", "Lax", "None"]
|
||||
|
||||
|
||||
def set_cookie(resp):
|
||||
"""
|
||||
Sets a new CSRF protection cookie to the response. The backend should call
|
||||
this function every time it serves the index endpoint (`index.html`), in
|
||||
order to refresh the cookie.
|
||||
- The frontend should be able to read this cookie: HttpOnly=False
|
||||
- The cookie should only be sent with HTTPS: Secure=True
|
||||
- The cookie should only live in the app's path and not in the entire
|
||||
domain. Path={app.prefix}
|
||||
|
||||
Finally, disable caching for the endpoint that calls this function, which
|
||||
should be the index endpoint.
|
||||
"""
|
||||
cookie = secrets.token_urlsafe(32)
|
||||
|
||||
secure = settings.SECURE_COOKIES
|
||||
if not secure:
|
||||
log.info("Not setting Secure in CSRF cookie.")
|
||||
|
||||
samesite = os.getenv("CSRF_SAMESITE", "Strict")
|
||||
if samesite not in SAMESITE_VALUES:
|
||||
samesite = "Strict"
|
||||
|
||||
resp.set_cookie(key=CSRF_COOKIE, value=cookie, samesite=samesite,
|
||||
httponly=False, secure=secure,
|
||||
path=current_app.config["PREFIX"])
|
||||
|
||||
# Don't cache a response that sets a CSRF cookie
|
||||
no_cache = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
resp.headers["Cache-Control"] = no_cache
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def check_endpoint():
|
||||
safe_methods = ["GET", "HEAD", "OPTIONS", "TRACE"]
|
||||
if request.method in safe_methods:
|
||||
log.info("Skipping CSRF check for safe method: %s", request.method)
|
||||
return
|
||||
|
||||
log.debug("Ensuring endpoint is CSRF protected: %s", request.path)
|
||||
if CSRF_COOKIE not in request.cookies:
|
||||
raise Forbidden("Could not find CSRF cookie %s in the request."
|
||||
% CSRF_COOKIE)
|
||||
|
||||
if CSRF_HEADER not in request.headers:
|
||||
raise Forbidden("Could not detect CSRF protection header %s."
|
||||
% CSRF_HEADER)
|
||||
|
||||
header_token = request.headers[CSRF_HEADER]
|
||||
cookie_token = request.cookies[CSRF_COOKIE]
|
||||
if header_token != cookie_token:
|
||||
raise Forbidden("CSRF check failed. Token in cookie %s doesn't match "
|
||||
"token in header %s." % (CSRF_COOKIE, CSRF_HEADER))
|
||||
|
||||
return
|
|
@ -0,0 +1,44 @@
|
|||
import functools
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from werkzeug import exceptions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def request_is_json_type(func):
|
||||
"""Make sure that the current request is of type JSON"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if request.content_type != "application/json":
|
||||
raise exceptions.BadRequest("Request is not in JSON format.")
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def required_body_params(*params):
|
||||
"""
|
||||
Used to decorate a route that accepts json and must contain some specific
|
||||
fields. If a field is not present then the server will return a 400 error.
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
@functools.wraps(func)
|
||||
def runner(*args, **kwargs):
|
||||
body = request.get_json()
|
||||
for param in params:
|
||||
if param not in body:
|
||||
raise exceptions.BadRequest(
|
||||
"Parameter '%s' is missing from the request's"
|
||||
" body." % param
|
||||
)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return runner
|
||||
|
||||
return wrapper
|
|
@ -0,0 +1,6 @@
|
|||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("errors", __name__)
|
||||
|
||||
from . import handlers # noqa F401, E402
|
||||
from .utils import failed_response, parse_error_message # noqa F401, E402
|
|
@ -0,0 +1,41 @@
|
|||
import logging
|
||||
|
||||
from flask import request
|
||||
from kubernetes.client.rest import ApiException
|
||||
from werkzeug import exceptions
|
||||
|
||||
from .. import api
|
||||
from . import bp, utils
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@bp.app_errorhandler(ApiException)
|
||||
def api_exception_handler(e):
|
||||
"""
|
||||
If the backend could not complete the k8s API call then the default handler
|
||||
will catch the exception, format the error message and return an
|
||||
appropriate response for the frontend
|
||||
"""
|
||||
ep = request.url
|
||||
log.error("An error occured talking to k8s while working on %s: %s", ep, e)
|
||||
|
||||
if e.status == 404:
|
||||
msg = "The requested resource could not be found in the API Server"
|
||||
else:
|
||||
msg = utils.parse_error_message(e)
|
||||
|
||||
return api.failed_response(msg, e.status)
|
||||
|
||||
|
||||
@bp.app_errorhandler(exceptions.HTTPException)
|
||||
def handle_http_errors(e):
|
||||
log.error("HTTP Exception handled: %s", e)
|
||||
return api.failed_response(e.description, e.code)
|
||||
|
||||
|
||||
@bp.app_errorhandler(Exception)
|
||||
def catch_all(e):
|
||||
log.error("Caught and unhandled Exception!")
|
||||
log.exception(e)
|
||||
return api.failed_response("An error occured in the backend.", 500)
|
|
@ -0,0 +1,22 @@
|
|||
import json
|
||||
|
||||
|
||||
def failed_response(msg, error_code):
|
||||
content = {
|
||||
"success": False,
|
||||
"log": msg,
|
||||
"status": error_code,
|
||||
}
|
||||
|
||||
return content, error_code
|
||||
|
||||
|
||||
def parse_error(e):
|
||||
try:
|
||||
return json.loads(e.body)
|
||||
except (json.JSONDecodeError, KeyError, AttributeError):
|
||||
return {}
|
||||
|
||||
|
||||
def parse_error_message(e):
|
||||
return parse_error(e).get("message", str(e))
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
Common helper functions for handling k8s objects information
|
||||
"""
|
||||
import datetime as dt
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import yaml
|
||||
from flask import current_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_prefixed_index_html():
|
||||
"""
|
||||
The backend should modify the <base> element of the index.html file to
|
||||
align with the configured prefix the backend is listening
|
||||
"""
|
||||
prefix = os.path.join("/", current_app.config["PREFIX"], "")
|
||||
static_dir = current_app.config["STATIC_DIR"]
|
||||
|
||||
log.info("Setting the <base> to reflect the prefix: %s", prefix)
|
||||
with open(os.path.join(static_dir, "index.html"), "r") as f:
|
||||
index_html = f.read()
|
||||
index_prefixed = re.sub(
|
||||
r"\<base href=\".*\".*\>", '<base href="%s">' % prefix, index_html,
|
||||
)
|
||||
|
||||
return index_prefixed
|
||||
|
||||
|
||||
def load_yaml(f):
|
||||
"""
|
||||
f: file path
|
||||
Load a yaml file and convert it to a python dict.
|
||||
"""
|
||||
c = None
|
||||
try:
|
||||
with open(f, "r") as yaml_file:
|
||||
c = yaml_file.read()
|
||||
except IOError:
|
||||
log.error("Error opening: %s", f)
|
||||
return None
|
||||
|
||||
try:
|
||||
contents = yaml.safe_load(c)
|
||||
if contents is None:
|
||||
# YAML exists but is empty
|
||||
return {}
|
||||
else:
|
||||
# YAML exists and is not empty
|
||||
return contents
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
|
||||
def load_param_yaml(f, **kwargs):
|
||||
"""
|
||||
f: file path
|
||||
|
||||
Load a yaml file and convert it to a python dict. The yaml might have some
|
||||
`{var}` values which the user will have to format. For this we first read
|
||||
the yaml file and replace these variables and then convert the generated
|
||||
string to a dict via the yaml module.
|
||||
"""
|
||||
c = None
|
||||
try:
|
||||
with open(f, "r") as yaml_file:
|
||||
c = yaml_file.read().format(**kwargs)
|
||||
except IOError:
|
||||
log.error("Error opening: %s", f)
|
||||
return None
|
||||
|
||||
try:
|
||||
contents = yaml.safe_load(c)
|
||||
if contents is None:
|
||||
# YAML exists but is empty
|
||||
return {}
|
||||
else:
|
||||
# YAML exists and is not empty
|
||||
return contents
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
|
||||
def get_uptime(then):
|
||||
"""
|
||||
then: datetime instance | string
|
||||
|
||||
Return a string that informs how much time has pasted from the provided
|
||||
timestamp.
|
||||
"""
|
||||
if isinstance(then, str):
|
||||
then = dt.datetime.strptime(then, "%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
now = dt.datetime.now()
|
||||
diff = now - then.replace(tzinfo=None)
|
||||
|
||||
days = diff.days
|
||||
hours = int(diff.seconds / 3600)
|
||||
mins = int((diff.seconds % 3600) / 60)
|
||||
|
||||
age = ""
|
||||
if days > 0:
|
||||
if days == 1:
|
||||
age = str(days) + " day"
|
||||
else:
|
||||
age = str(days) + " days"
|
||||
else:
|
||||
if hours > 0:
|
||||
if hours == 1:
|
||||
age = str(hours) + " hour"
|
||||
else:
|
||||
age = str(hours) + " hours"
|
||||
else:
|
||||
if mins == 0:
|
||||
return "just now"
|
||||
if mins == 1:
|
||||
age = str(mins) + " min"
|
||||
else:
|
||||
age = str(mins) + " mins"
|
||||
|
||||
return age + " ago"
|
|
@ -0,0 +1,49 @@
|
|||
import logging
|
||||
import sys
|
||||
|
||||
from flask import Flask
|
||||
from prometheus_flask_exporter import PrometheusMetrics
|
||||
|
||||
from .authn import no_authentication
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_backend_version() -> str:
|
||||
"""Get the backend version.
|
||||
|
||||
The version is defined in setup.py.
|
||||
"""
|
||||
if sys.version_info >= (3, 8):
|
||||
from importlib import metadata
|
||||
else:
|
||||
import importlib_metadata as metadata
|
||||
|
||||
return metadata.version("kubeflow")
|
||||
|
||||
|
||||
def enable_metrics(app: Flask) -> None:
|
||||
"""Enable Prometheus metrics fro backend app.
|
||||
|
||||
This function will enable metrics collection for all routes and expose them
|
||||
at /metrics.
|
||||
|
||||
Default metrics are:
|
||||
flask_http_request_duration_seconds (Histogram)
|
||||
flask_http_request_total (Counter)
|
||||
flask_http_request_exceptions_total (Counter)
|
||||
flask_exporter_info (Gauge)
|
||||
"""
|
||||
log.info("Enabling the Prometheus metrics for %s", app.name)
|
||||
backend_version = _get_backend_version()
|
||||
log.debug("Backend version is %s", backend_version)
|
||||
metrics = PrometheusMetrics(
|
||||
app,
|
||||
group_by="url_rule",
|
||||
default_labels={"app": app.name},
|
||||
metrics_decorator=no_authentication,
|
||||
)
|
||||
# add default metrics with info about app
|
||||
metrics.info(
|
||||
"app_info", "Application info", version=backend_version, app=app.name
|
||||
)
|
|
@ -0,0 +1,17 @@
|
|||
from flask import Blueprint, jsonify
|
||||
|
||||
from . import authn
|
||||
|
||||
bp = Blueprint("probes", __name__)
|
||||
|
||||
|
||||
@bp.route("/healthz/liveness")
|
||||
@authn.no_authentication
|
||||
def liveness():
|
||||
return jsonify("alive"), 200
|
||||
|
||||
|
||||
@bp.route("/healthz/readiness")
|
||||
@authn.no_authentication
|
||||
def readiness():
|
||||
return jsonify("ready"), 200
|
|
@ -0,0 +1,6 @@
|
|||
Flask==1.1.1
|
||||
Flask-API==2.0
|
||||
kubernetes==10.0.1
|
||||
requests==2.22.0
|
||||
urllib3==1.26.18
|
||||
Werkzeug==0.16.0
|
|
@ -0,0 +1,5 @@
|
|||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint("base_lib_get_routes", __name__)
|
||||
|
||||
from . import get # noqa E402, F401
|
|
@ -0,0 +1,50 @@
|
|||
from .. import api
|
||||
from . import bp
|
||||
|
||||
|
||||
@bp.route("/info")
|
||||
def get_info():
|
||||
return api.success_response("info", {})
|
||||
|
||||
|
||||
@bp.route("/api/namespaces")
|
||||
def get_namespaces():
|
||||
namespaces = api.list_namespaces()
|
||||
content = [ns.metadata.name for ns in namespaces.items]
|
||||
|
||||
return api.success_response("namespaces", content)
|
||||
|
||||
|
||||
@bp.route("/api/storageclasses")
|
||||
def get_storageclasses():
|
||||
scs = api.list_storageclasses()
|
||||
content = [sc.metadata.name for sc in scs.items]
|
||||
|
||||
return api.success_response("storageClasses", content)
|
||||
|
||||
|
||||
@bp.route("/api/storageclasses/default")
|
||||
def get_default_storageclass():
|
||||
scs = api.list_storageclasses()
|
||||
|
||||
for sc in scs.items:
|
||||
annotations = sc.metadata.annotations
|
||||
if annotations is None:
|
||||
continue
|
||||
|
||||
# List of possible annotations
|
||||
keys = [
|
||||
"storageclass.kubernetes.io/is-default-class",
|
||||
"storageclass.beta.kubernetes.io/is-default-class", # GKE
|
||||
]
|
||||
|
||||
for key in keys:
|
||||
default_sc_annotation = annotations.get(key, "false")
|
||||
|
||||
if default_sc_annotation == "true":
|
||||
return api.success_response(
|
||||
"defaultStorageClass", sc.metadata.name
|
||||
)
|
||||
|
||||
# No StorageClass is default
|
||||
return api.success_response("defaultStorageClass", "")
|
|
@ -0,0 +1,31 @@
|
|||
import logging
|
||||
|
||||
from flask import Blueprint, Response
|
||||
|
||||
from . import csrf, helpers
|
||||
|
||||
bp = Blueprint("serving", __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Caching: We want the browser to always check if the index.html file it has
|
||||
# is the latest one. Also we want this check to use the ETag header and not
|
||||
# the Last-Modified since our app will be served inside a container and we
|
||||
# might want to roll-back.
|
||||
# The JS/CSS files will have a hash in their name and thus we will want to
|
||||
# cache them for as long as possible
|
||||
|
||||
|
||||
@bp.route("/index.html")
|
||||
@bp.route("/")
|
||||
@bp.route("/<path:path>")
|
||||
def serve_index(path="/"):
|
||||
# Serve the index file in all other cases
|
||||
log.info("Serving index.html for path: %s", path)
|
||||
|
||||
no_cache = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
resp = Response(helpers.get_prefixed_index_html(), mimetype="text/html",
|
||||
headers={"Cache-Control": no_cache})
|
||||
|
||||
csrf.set_cookie(resp)
|
||||
|
||||
return resp
|
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
SECURE_COOKIES = os.getenv("APP_SECURE_COOKIES", "true").lower() == "true"
|
||||
DISABLE_AUTH = os.getenv("APP_DISABLE_AUTH", "false").lower() == "true"
|
||||
USER_HEADER = os.getenv("USERID_HEADER", "kubeflow-userid")
|
||||
USER_PREFIX = os.getenv("USERID_PREFIX", ":")
|
|
@ -0,0 +1,22 @@
|
|||
class STATUS_PHASE:
|
||||
"""
|
||||
Different values that the status phase should have. The frontend will be
|
||||
expecting only these values.
|
||||
"""
|
||||
|
||||
READY = "ready"
|
||||
WAITING = "waiting"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
UNINITIALIZED = "uninitialized"
|
||||
UNAVAILABLE = "unavailable"
|
||||
TERMINATING = "terminating"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
def create_status(phase="", message="", state="", key=None):
|
||||
return {
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"state": state,
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import setuptools
|
||||
|
||||
REQUIRES = [
|
||||
"Flask >= 1.1.1",
|
||||
"Flask-API >= 2.0",
|
||||
"kubernetes == 22.6.0",
|
||||
"requests >= 2.22.0",
|
||||
"urllib3 >= 1.25.7",
|
||||
"Werkzeug >= 0.16.0",
|
||||
"Flask-Cors >= 3.0.8",
|
||||
"gevent",
|
||||
"prometheus-flask-exporter >= 0.23.1",
|
||||
"importlib-metadata >= 1.0;python_version<'3.8'",
|
||||
]
|
||||
|
||||
setuptools.setup(
|
||||
name="kubeflow",
|
||||
version="1.2",
|
||||
author="kubeflow-dev-team",
|
||||
description="A package with a base Flask CRUD backend common code",
|
||||
packages=setuptools.find_packages(),
|
||||
install_requires=REQUIRES,
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Software Development",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
python_requires=">=3.6",
|
||||
)
|
|
@ -0,0 +1,5 @@
|
|||
approvers:
|
||||
- elenzio9
|
||||
- orfeas-k
|
||||
emeritus_approvers:
|
||||
- tasos-ale
|
|
@ -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
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"tsconfig.json",
|
||||
"e2e/tsconfig.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/ng-cli-compat",
|
||||
"plugin:@angular-eslint/ng-cli-compat--formatting-add-on",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"off",
|
||||
{
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/member-ordering": "off",
|
||||
"@angular-eslint/no-input-rename": "off",
|
||||
"prefer-arrow/prefer-arrow-functions": "off",
|
||||
"arrow-parens": [
|
||||
"off",
|
||||
"always"
|
||||
],
|
||||
"import/order": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
# 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
|
|
@ -0,0 +1,43 @@
|
|||
# Kubeflow Common Frontend Library
|
||||
|
||||
This code provides a common library of reusable Angular Components that can be used from our different Kubeflow web apps. This library aims to:
|
||||
* Enforce a common UX throughout the different apps
|
||||
* Reduce the development effort required to propagate changes to all the web apps
|
||||
* Minimize the code duplication between our Kubeflow web apps
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.3.20, which is required to build and run the unit tests.
|
||||
|
||||
## Local development
|
||||
In order to use this library while developing locally your Angular app you will need to:
|
||||
1. Build the `kubeflow` node module from this source code
|
||||
2. Link the produced module to your global npm modules
|
||||
3. Link the `kubeflow` module in the npm modules of you app
|
||||
|
||||
### Building the library locally
|
||||
```bash
|
||||
# build the npm module
|
||||
npm run build
|
||||
|
||||
# might need sudo, depending on where you global folder lives
|
||||
# https://nodejs.dev/learn/where-does-npm-install-the-packages
|
||||
npm link dist/kubeflow
|
||||
```
|
||||
### Linking it to the app
|
||||
```bash
|
||||
cd ${APP_DIR}
|
||||
npm install
|
||||
npm link kubeflow
|
||||
```
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Contributor Guidelines
|
||||
|
||||
### Unit tests
|
||||
1. Any new component added to this library should also include some basic unit tests
|
||||
2. The unit tests should be passing at any point of time
|
||||
|
||||
### Git commits
|
||||
Git commits that modify this code should be prefixed with `web-apps(front)`.
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"kubeflow": {
|
||||
"projectType": "library",
|
||||
"root": "projects/kubeflow",
|
||||
"sourceRoot": "projects/kubeflow/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"tsConfig": "projects/kubeflow/tsconfig.lib.json",
|
||||
"project": "projects/kubeflow/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/kubeflow/tsconfig.lib.prod.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "projects/kubeflow/src/test.ts",
|
||||
"tsConfig": "projects/kubeflow/tsconfig.spec.json",
|
||||
"karmaConfig": "projects/kubeflow/karma.conf.js"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"projects/kubeflow/**/*.ts",
|
||||
"projects/kubeflow/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "kubeflow",
|
||||
"cli": {
|
||||
"defaultCollection": "@angular-eslint/schematics"
|
||||
}
|
||||
}
|
28980
components/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json
generated
Normal file
28980
components/crud-web-apps/common/frontend/kubeflow-common-lib/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"name": "angular-frontend-kubeflow",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build && npm run copyCSS && npm run copyAssets",
|
||||
"test": "ng test",
|
||||
"test-ci": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI",
|
||||
"test-docker": "docker run -v $(pwd):/usr/src/app browserless/chrome:1.44-chrome-stable npm run test-ci",
|
||||
"test:prod": "ng test --browsers=ChromeHeadless --watch=false",
|
||||
"lint-check": "ng lint",
|
||||
"lint": "ng lint --fix",
|
||||
"e2e": "ng e2e",
|
||||
"copyCSS": "cp -r ./projects/kubeflow/src/styles ./dist/kubeflow/styles",
|
||||
"copyAssets": "cp -r ./projects/kubeflow/src/assets ./dist/kubeflow/assets",
|
||||
"postinstall": "ngcc",
|
||||
"format:check": "prettier --check 'projects/kubeflow/src/**/*.{js,ts,html,scss,css}' || node scripts/check-format-error.js",
|
||||
"format:write": "prettier --write 'projects/kubeflow/src/**/*.{js,ts,html,scss,css}'"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "~14.3.0",
|
||||
"@angular/cdk": "~14.2.7",
|
||||
"@angular/cdk-experimental": "~14.2.7",
|
||||
"@angular/common": "~14.3.0",
|
||||
"@angular/compiler": "~14.3.0",
|
||||
"@angular/core": "~14.3.0",
|
||||
"@angular/forms": "~14.3.0",
|
||||
"@angular/localize": "~14.3.0",
|
||||
"@angular/material": "~14.2.7",
|
||||
"@angular/platform-browser": "~14.3.0",
|
||||
"@angular/platform-browser-dynamic": "~14.3.0",
|
||||
"@angular/router": "~14.3.0",
|
||||
"@fortawesome/angular-fontawesome": "~0.11.1",
|
||||
"@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": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"material-icons": "^0.7.7",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"rxjs": "~7.4.0",
|
||||
"tslib": "^2.0.0",
|
||||
"zone.js": "~0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~14.2.13",
|
||||
"@angular-devkit/core": "^14.2.13",
|
||||
"@angular-eslint/builder": "~14.4.0",
|
||||
"@angular-eslint/eslint-plugin": "~14.4.0",
|
||||
"@angular-eslint/eslint-plugin-template": "~14.4.0",
|
||||
"@angular-eslint/schematics": "~14.4.0",
|
||||
"@angular-eslint/template-parser": "~14.4.0",
|
||||
"@angular/cli": "~14.2.13",
|
||||
"@angular/compiler-cli": "~14.3.0",
|
||||
"@angular/language-service": "~14.3.0",
|
||||
"@kubernetes/client-node": "^0.16.3",
|
||||
"@types/jasmine": "~3.6.0",
|
||||
"@types/jasminewd2": "^2.0.9",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/node": "^12.11.1",
|
||||
"@typescript-eslint/eslint-plugin": "4.28.2",
|
||||
"@typescript-eslint/parser": "4.28.2",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-plugin-import": "latest",
|
||||
"eslint-plugin-jsdoc": "^34.0.0",
|
||||
"eslint-plugin-prefer-arrow": "latest",
|
||||
"jasmine-core": "~3.8.0",
|
||||
"jasmine-spec-reporter": "~5.0.0",
|
||||
"karma": "~6.3.16",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "~3.0.2",
|
||||
"karma-jasmine": "~4.0.0",
|
||||
"karma-jasmine-html-reporter": "^1.6.0",
|
||||
"ng-packagr": "~14.2.2",
|
||||
"prettier": "2.3.2",
|
||||
"process": "^0.11.10",
|
||||
"ts-node": "^10.4.0",
|
||||
"typescript": "~4.8.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"extends": "../../.eslintrc.json",
|
||||
"ignorePatterns": [
|
||||
"!**/*",
|
||||
"lib/editor/interfaces/monaco.ts"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"projects/kubeflow/tsconfig.lib.json",
|
||||
"projects/kubeflow/tsconfig.spec.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"rules": {
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "lib",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "lib",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"off",
|
||||
{
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
],
|
||||
"arrow-parens": [
|
||||
"off",
|
||||
"always"
|
||||
],
|
||||
"import/order": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"printWidth": 80,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"proseWrap": "preserve"
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
# Kubeflow
|
||||
|
||||
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 8.2.14.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name --project kubeflow` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project kubeflow`.
|
||||
|
||||
> Note: Don't forget to add `--project kubeflow` or else it will be added to the default project in your `angular.json` file.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build kubeflow` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Publishing
|
||||
|
||||
After building your library with `ng build kubeflow`, go to the dist folder `cd dist/kubeflow` and run `npm publish`.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test kubeflow` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI documents](https://angular.io/cli).
|
|
@ -0,0 +1,56 @@
|
|||
// 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'),
|
||||
],
|
||||
files: [
|
||||
{
|
||||
pattern: 'node_modules/monaco-editor/**',
|
||||
watched: false,
|
||||
included: false,
|
||||
served: true,
|
||||
},
|
||||
{
|
||||
pattern: 'projects/kubeflow/src/assets/**',
|
||||
watched: false,
|
||||
included: false,
|
||||
served: true,
|
||||
},
|
||||
],
|
||||
proxies: {
|
||||
'/static/assets/monaco-editor/': '/base/node_modules/monaco-editor/',
|
||||
'/static/assets/': '/base/projects/kubeflow/src/assets/',
|
||||
},
|
||||
client: {
|
||||
clearContext: true,
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, '../../coverage/kubeflow'),
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
fixWebpackSourcePaths: true,
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
customLaunchers: {
|
||||
ChromeHeadlessCI: {
|
||||
base: 'ChromeHeadless',
|
||||
flags: ['--no-sandbox'],
|
||||
},
|
||||
},
|
||||
singleRun: false,
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/kubeflow",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
5
components/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/package-lock.json
generated
Normal file
5
components/crud-web-apps/common/frontend/kubeflow-common-lib/projects/kubeflow/package-lock.json
generated
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "kubeflow",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 1
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "kubeflow",
|
||||
"version": "0.0.4",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "~14.3.0",
|
||||
"@angular/cdk": "~14.2.7",
|
||||
"@angular/cdk-experimental": "~14.2.7",
|
||||
"@angular/core": "~14.3.0",
|
||||
"@angular/animations": "~14.3.0",
|
||||
"@angular/compiler": "~14.3.0",
|
||||
"@angular/forms": "~14.3.0",
|
||||
"@angular/material": "~14.2.7",
|
||||
"@angular/localize": "~14.3.0",
|
||||
"date-fns": "1.29.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"material-icons": "^0.7.7",
|
||||
"zone.js": "~0.11.4",
|
||||
"@kubernetes/client-node": "^0.12.2"
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,5 @@
|
|||
<lib-resource-table
|
||||
[config]="config"
|
||||
[data]="conditions"
|
||||
[trackByFn]="conditionsTrackByFn"
|
||||
></lib-resource-table>
|
|
@ -0,0 +1,27 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { ConditionsTableComponent } from './conditions-table.component';
|
||||
import { ConditionsTableModule } from './conditions-table.module';
|
||||
|
||||
describe('ConditionsTableComponent', () => {
|
||||
let component: ConditionsTableComponent;
|
||||
let fixture: ComponentFixture<ConditionsTableComponent>;
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ConditionsTableModule],
|
||||
}).compileComponents();
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConditionsTableComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Condition, ConditionIR } from './types';
|
||||
import { STATUS_TYPE } from '../resource-table/status/types';
|
||||
import { generateConfig } from './config';
|
||||
import { TableConfig } from '../resource-table/types';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-conditions-table',
|
||||
templateUrl: './conditions-table.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: [],
|
||||
})
|
||||
export class ConditionsTableComponent {
|
||||
private conditionsPrv: ConditionIR[] = [];
|
||||
public config: TableConfig = generateConfig();
|
||||
|
||||
@Input()
|
||||
set title(t: string) {
|
||||
this.config.title = t;
|
||||
}
|
||||
|
||||
@Input()
|
||||
set conditions(cs: ConditionIR[]) {
|
||||
this.conditionsPrv = JSON.parse(JSON.stringify(cs));
|
||||
|
||||
// parse the status. It should be ready only if it was True
|
||||
for (const condition of this.conditionsPrv) {
|
||||
condition.statusPhase = STATUS_TYPE.WARNING;
|
||||
if (condition.status === 'True') {
|
||||
condition.statusPhase = STATUS_TYPE.READY;
|
||||
}
|
||||
|
||||
// Set default values that are necessary for sorting functionality
|
||||
if (!condition.lastTransitionTime) {
|
||||
condition.lastTransitionTime = '';
|
||||
}
|
||||
if (!condition.message) {
|
||||
condition.message = '';
|
||||
}
|
||||
if (!condition.reason) {
|
||||
condition.reason = '';
|
||||
}
|
||||
|
||||
condition.statusMessage = condition.status;
|
||||
}
|
||||
}
|
||||
|
||||
get conditions(): ConditionIR[] {
|
||||
return this.conditionsPrv;
|
||||
}
|
||||
|
||||
public conditionsTrackByFn(index: number, c: Condition) {
|
||||
return `${c.type}/${c.lastTransitionTime}`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ResourceTableModule } from '../resource-table/resource-table.module';
|
||||
import { ConditionsTableComponent } from './conditions-table.component';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConditionsTableComponent],
|
||||
imports: [CommonModule, ResourceTableModule, BrowserAnimationsModule],
|
||||
exports: [ConditionsTableComponent],
|
||||
})
|
||||
export class ConditionsTableModule {}
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
PropertyValue,
|
||||
StatusValue,
|
||||
TABLE_THEME,
|
||||
TableConfig,
|
||||
} from '../resource-table/types';
|
||||
import { DateTimeValue } from '../resource-table/types/date-time';
|
||||
|
||||
export function generateConfig(): TableConfig {
|
||||
return {
|
||||
title: '',
|
||||
width: '100%',
|
||||
theme: TABLE_THEME.FLAT,
|
||||
columns: [
|
||||
{
|
||||
matHeaderCellDef: 'Status',
|
||||
matColumnDef: 'status',
|
||||
style: { width: '40px' },
|
||||
value: new StatusValue({
|
||||
fieldPhase: 'statusPhase',
|
||||
fieldMessage: 'statusMessage',
|
||||
}),
|
||||
sort: true,
|
||||
},
|
||||
{
|
||||
matHeaderCellDef: 'Type',
|
||||
matColumnDef: 'type',
|
||||
style: { width: '150px' },
|
||||
value: new PropertyValue({
|
||||
field: 'type',
|
||||
}),
|
||||
sort: true,
|
||||
},
|
||||
{
|
||||
matHeaderCellDef: 'Last Transition Time',
|
||||
matColumnDef: 'lastTransitionTime',
|
||||
style: { width: '150px' },
|
||||
value: new DateTimeValue({
|
||||
field: 'lastTransitionTime',
|
||||
}),
|
||||
sort: true,
|
||||
},
|
||||
{
|
||||
matHeaderCellDef: 'Reason',
|
||||
matColumnDef: 'reason',
|
||||
style: { width: '150px' },
|
||||
value: new PropertyValue({
|
||||
field: 'reason',
|
||||
}),
|
||||
sort: true,
|
||||
},
|
||||
{
|
||||
matHeaderCellDef: 'Message',
|
||||
matColumnDef: 'message',
|
||||
style: { width: '150px' },
|
||||
value: new PropertyValue({
|
||||
field: 'message',
|
||||
}),
|
||||
sort: true,
|
||||
},
|
||||
],
|
||||
sortByColumn: 'lastTransitionTime',
|
||||
sortDirection: 'desc',
|
||||
};
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
export interface Condition {
|
||||
type: string;
|
||||
status: string;
|
||||
lastTransitionTime?: string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ConditionIR extends Condition {
|
||||
statusPhase?: string;
|
||||
statusMessage?: string;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ConfirmDialogComponent } from './dialog/dialog.component';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
})
|
||||
export class ConfirmDialogModule {}
|
|
@ -0,0 +1,19 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmDialogService } from './confirm-dialog.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ConfirmDialogModule } from './confirm-dialog.module';
|
||||
|
||||
describe('ConfirmDialogService', () => {
|
||||
beforeEach(() =>
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ConfirmDialogModule],
|
||||
providers: [MatDialog],
|
||||
}),
|
||||
);
|
||||
|
||||
it('should be created', () => {
|
||||
const service: ConfirmDialogService = TestBed.inject(ConfirmDialogService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { ConfirmDialogModule } from './confirm-dialog.module';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ConfirmDialogComponent } from './dialog/dialog.component';
|
||||
import { DialogConfig } from './types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: ConfirmDialogModule,
|
||||
})
|
||||
export class ConfirmDialogService {
|
||||
constructor(private dialog: MatDialog) {}
|
||||
|
||||
public open(rsrcName: string, config: DialogConfig) {
|
||||
return this.dialog.open(ConfirmDialogComponent, {
|
||||
width: config.width || 'fit-content',
|
||||
data: config,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
<h1 mat-dialog-title>{{ data.title }}</h1>
|
||||
<div mat-dialog-content>
|
||||
<p>{{ data.message }}</p>
|
||||
|
||||
<p class="error">{{ data.error }}</p>
|
||||
</div>
|
||||
<div mat-dialog-actions>
|
||||
<button mat-button [mat-dialog-close]="DIALOG_RESP.CANCEL" cdkFocusInitial>
|
||||
{{ data.cancel.toUpperCase() }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="!isApplying"
|
||||
mat-button
|
||||
(click)="onAcceptClicked()"
|
||||
[color]="data.confirmColor"
|
||||
>
|
||||
{{ data.accept.toUpperCase() }}
|
||||
</button>
|
||||
|
||||
<button *ngIf="isApplying" mat-button disabled>
|
||||
<div class="waiting-button-wrapper">
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
<p>{{ data.applying }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
|
@ -0,0 +1,11 @@
|
|||
.waiting-button-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.waiting-button-wrapper mat-spinner {
|
||||
margin: auto 0.2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmDialogComponent } from './dialog.component';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { ConfirmDialogModule } from '../confirm-dialog.module';
|
||||
|
||||
describe('ConfirmDialogComponent', () => {
|
||||
let component: ConfirmDialogComponent;
|
||||
let fixture: ComponentFixture<ConfirmDialogComponent>;
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ConfirmDialogModule],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: {} },
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
title: '',
|
||||
message: '',
|
||||
accept: '',
|
||||
applying: '',
|
||||
error: '',
|
||||
confirmColor: '',
|
||||
cancel: '',
|
||||
width: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
import { Component, OnInit, Inject, EventEmitter } from '@angular/core';
|
||||
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { DialogConfig, DIALOG_RESP } from '../types';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-confirm-dialog',
|
||||
templateUrl: './dialog.component.html',
|
||||
styleUrls: ['./dialog.component.scss'],
|
||||
})
|
||||
export class ConfirmDialogComponent implements OnInit {
|
||||
public DIALOG_RESP = DIALOG_RESP;
|
||||
public isApplying = false;
|
||||
public applying$ = new Subject<boolean>();
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
|
||||
|
||||
@Inject(MAT_DIALOG_DATA)
|
||||
public data: DialogConfig,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.applying$.subscribe(b => {
|
||||
this.isApplying = b;
|
||||
});
|
||||
}
|
||||
|
||||
onAcceptClicked(): void {
|
||||
this.isApplying = true;
|
||||
this.applying$.next(true);
|
||||
}
|
||||
|
||||
onCancelClicked(): void {
|
||||
this.dialogRef.close(DIALOG_RESP.CANCEL);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
export interface DialogConfig {
|
||||
title: string;
|
||||
message: string;
|
||||
accept: string;
|
||||
applying: string;
|
||||
error?: string;
|
||||
confirmColor: string;
|
||||
cancel: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export enum DIALOG_RESP {
|
||||
CANCEL = 'cancel',
|
||||
ACCEPT = 'accept',
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
<mat-divider *ngIf="topDivider"></mat-divider>
|
||||
|
||||
<div class="list-entry-row">
|
||||
<!--Key-Title Row-->
|
||||
<div
|
||||
class="list-entry-key vertical-align"
|
||||
[matTooltip]="keyTooltip"
|
||||
[style.min-width]="keyMinWidth"
|
||||
>
|
||||
{{ key }}
|
||||
</div>
|
||||
|
||||
<!--Values-Subtable Row-->
|
||||
<div class="container">
|
||||
<ng-content class="vertical-align"> </ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider *ngIf="bottomDivider"></mat-divider>
|
|
@ -0,0 +1,26 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.list-entry-row {
|
||||
padding: 0.4rem 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.list-entry-key {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
.vertical-align {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.mat-divider {
|
||||
margin: 4px 0;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { ContentListItemComponent } from './content-list-item.component';
|
||||
|
||||
describe('ContentListItemComponent', () => {
|
||||
let component: ContentListItemComponent;
|
||||
let fixture: ComponentFixture<ContentListItemComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ContentListItemComponent],
|
||||
imports: [CommonModule, MatDividerModule, MatTooltipModule],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ContentListItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-content-list-item',
|
||||
templateUrl: './content-list-item.component.html',
|
||||
styleUrls: ['./content-list-item.component.scss'],
|
||||
})
|
||||
export class ContentListItemComponent implements OnInit {
|
||||
@Input() key: string;
|
||||
@Input() keyTooltip: string;
|
||||
@Input() topDivider = false;
|
||||
@Input() bottomDivider = true;
|
||||
@Input() keyMinWidth = '250px';
|
||||
@Input() loadErrorMsg = 'Resources not available';
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ContentListItemComponent } from './content-list-item.component';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ContentListItemComponent],
|
||||
imports: [CommonModule, MatDividerModule, MatTooltipModule],
|
||||
exports: [ContentListItemComponent],
|
||||
})
|
||||
export class ContentListItemModule {}
|
|
@ -0,0 +1,20 @@
|
|||
<div
|
||||
[libPopover]="timeTpl"
|
||||
[libPopoverPosition]="popoverPosition"
|
||||
[libPopoverDisabled]="isPopoverDisabled"
|
||||
[libPopoverHideDelay]="100"
|
||||
[libPopoverShowDelay]="100"
|
||||
class="truncate"
|
||||
>
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
|
||||
<ng-template #timeTpl>
|
||||
<lib-details-list-item key="Local" [bottomDivider]="false" keyMinWidth="50px">
|
||||
{{ date | libToDate }}
|
||||
</lib-details-list-item>
|
||||
|
||||
<lib-details-list-item key="UTC" [bottomDivider]="false" keyMinWidth="50px">
|
||||
{{ date }}
|
||||
</lib-details-list-item>
|
||||
</ng-template>
|
|
@ -0,0 +1,28 @@
|
|||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { DateTimeComponent } from './date-time.component';
|
||||
import { ToDatePipe } from './to-date.pipe';
|
||||
import { DateTimeModule } from './date-time.module';
|
||||
|
||||
describe('DateTimeComponent', () => {
|
||||
let component: DateTimeComponent;
|
||||
let fixture: ComponentFixture<DateTimeComponent>;
|
||||
|
||||
beforeEach(
|
||||
waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DateTimeModule],
|
||||
}).compileComponents();
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DateTimeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
Input,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import { DateTimeService } from '../services/date-time.service';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-date-time',
|
||||
templateUrl: './date-time.component.html',
|
||||
styleUrls: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DateTimeComponent implements OnDestroy {
|
||||
private timer: number;
|
||||
private defaultDisplayValuePrv = '-';
|
||||
private datePrv: string | Date;
|
||||
|
||||
@Input()
|
||||
get date(): string | Date {
|
||||
return this.datePrv;
|
||||
}
|
||||
set date(v: string | Date) {
|
||||
this.datePrv = v;
|
||||
this.formattedDate = this.timeAgo(v);
|
||||
}
|
||||
formattedDate: string;
|
||||
|
||||
@Input() popoverPosition = 'below';
|
||||
|
||||
@Input('default')
|
||||
set defaultDisplayValue(v: string) {
|
||||
this.defaultDisplayValuePrv = v;
|
||||
this.checkAndUpdate(this.date);
|
||||
}
|
||||
get defaultDisplayValue(): string {
|
||||
return this.defaultDisplayValuePrv;
|
||||
}
|
||||
|
||||
get isPopoverDisabled(): boolean {
|
||||
return !this.date;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private dtService: DateTimeService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
) {
|
||||
this.timer = window.setInterval(() => {
|
||||
if (this.date) {
|
||||
this.checkAndUpdate(this.date);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
private timeAgo(d: string | Date): string {
|
||||
if (!d) {
|
||||
return this.defaultDisplayValue;
|
||||
}
|
||||
return this.dtService.distanceInWords(d);
|
||||
}
|
||||
|
||||
private checkAndUpdate(date: string | Date) {
|
||||
const d = this.timeAgo(date);
|
||||
if (this.formattedDate !== d && this.cdRef) {
|
||||
this.formattedDate = d;
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DateTimeComponent } from './date-time.component';
|
||||
import { PopoverModule } from '../popover/popover.module';
|
||||
import { ToDatePipe } from './to-date.pipe';
|
||||
import { DetailsListModule } from '../details-list/details-list.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [DateTimeComponent, ToDatePipe],
|
||||
imports: [CommonModule, PopoverModule, DetailsListModule],
|
||||
exports: [DateTimeComponent, ToDatePipe],
|
||||
})
|
||||
export class DateTimeModule {}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue