├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── deploy ├── deployment.yaml ├── kustomization.yaml ├── rbac.yaml └── service.yaml ├── docs ├── alternatives.rst ├── conf.py ├── customization.rst ├── features.rst ├── getting-started.rst ├── index.rst ├── oauth2.rst ├── security.rst ├── setup.rst └── vision.rst ├── examples ├── cluster-registry │ ├── Dockerfile │ ├── README.md │ └── cluster-registry.py ├── oauth2-log-jwt-sub │ └── hooks.py └── oauth2-validate-github-token │ └── hooks.py ├── kube_web ├── __init__.py ├── __main__.py ├── cluster_discovery.py ├── cluster_manager.py ├── example_hooks.py ├── jinja2_filters.py ├── joins.py ├── kubernetes.py ├── main.py ├── query_params.py ├── resource_registry.py ├── selector.py ├── table.py ├── templates │ ├── assets │ │ ├── bulma.min.css │ │ ├── favicon.png │ │ ├── favicon.svg │ │ ├── fontawesome.min.js │ │ ├── kube-web.css │ │ ├── kube-web.js │ │ ├── regular.min.js │ │ ├── solid.min.js │ │ ├── sortable-theme-minimal.css │ │ ├── sortable.min.js │ │ └── themes │ │ │ ├── darkly │ │ │ ├── bulmaswatch.min.css │ │ │ ├── kube-web.css │ │ │ └── settings.yaml │ │ │ ├── default │ │ │ ├── bulmaswatch.min.css │ │ │ ├── kube-web.css │ │ │ └── settings.yaml │ │ │ ├── flatly │ │ │ ├── bulmaswatch.min.css │ │ │ ├── kube-web.css │ │ │ └── settings.yaml │ │ │ ├── slate │ │ │ ├── bulmaswatch.min.css │ │ │ ├── kube-web.css │ │ │ └── settings.yaml │ │ │ └── superhero │ │ │ ├── bulmaswatch.min.css │ │ │ ├── kube-web.css │ │ │ └── settings.yaml │ ├── base.html │ ├── cluster.html │ ├── clusters.html │ ├── error.html │ ├── partials │ │ ├── events.html │ │ ├── extrahead.html │ │ ├── footer.html │ │ ├── navbar.html │ │ ├── sidebar.html │ │ └── yaml.html │ ├── preferences.html │ ├── resource-list.html │ ├── resource-logs.html │ ├── resource-types.html │ ├── resource-view.html │ └── search.html └── web.py ├── poetry.lock ├── pyproject.toml └── tests ├── e2e ├── __init__.py ├── conftest.py ├── deployment.yaml ├── test-resources.yaml ├── test_list.py ├── test_preferences.py ├── test_search.py ├── test_view.py ├── test_web.py └── utils.py └── unit ├── test_cluster_manager.py ├── test_jinja2_filters.py ├── test_joins.py ├── test_kubernetes.py ├── test_main.py ├── test_selector.py ├── test_table.py └── test_web.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=240 3 | ignore=E722,W503,E741 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.pyc 3 | .coverage 4 | kind 5 | kubectl 6 | docs/_build/ 7 | .kube 8 | *.egg-info 9 | .mypy_cache/ 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | minimum_pre_commit_version: 1.21.0 2 | repos: 3 | # meta 4 | 5 | - repo: meta 6 | hooks: 7 | - id: check-hooks-apply 8 | - id: check-useless-excludes 9 | 10 | - repo: https://codeberg.org/hjacobs/kube-manifest-lint 11 | rev: 0.2.0 12 | hooks: 13 | - id: kube-manifest-lint 14 | exclude: kustomization.yaml 15 | 16 | # formatters 17 | - repo: https://github.com/asottile/reorder_python_imports 18 | rev: v2.3.0 19 | hooks: 20 | - id: reorder-python-imports 21 | 22 | - repo: https://github.com/ambv/black 23 | rev: 19.10b0 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/asottile/pyupgrade 28 | rev: v2.6.2 29 | hooks: 30 | - id: pyupgrade 31 | stages: [push] 32 | 33 | # linters 34 | 35 | - repo: https://github.com/PyCQA/bandit 36 | rev: 1.6.2 37 | hooks: 38 | - id: bandit 39 | args: ["-x", "tests"] 40 | stages: [push] 41 | 42 | - repo: https://github.com/PyCQA/pydocstyle 43 | rev: 5.0.2 44 | hooks: 45 | - id: pydocstyle 46 | args: ["--ignore=D10,D21,D202,D401"] 47 | 48 | - repo: local 49 | hooks: 50 | 51 | - id: safety 52 | name: safety 53 | entry: safety 54 | language: system 55 | pass_filenames: false 56 | args: ["check", "--bare"] 57 | stages: [push] 58 | 59 | - id: poetry 60 | name: poetry 61 | description: Validates the structure of the pyproject.toml file 62 | entry: poetry check 63 | language: system 64 | pass_filenames: false 65 | files: ^pyproject.toml$ 66 | stages: [push] 67 | 68 | - repo: https://github.com/adrienverge/yamllint 69 | rev: v1.23.0 70 | hooks: 71 | - id: yamllint 72 | args: ["--strict", "-d", "{rules: {line-length: {max: 180}}}"] 73 | 74 | - repo: https://github.com/pre-commit/mirrors-mypy 75 | rev: v0.782 76 | hooks: 77 | - id: mypy 78 | exclude: "^examples/oauth2-log-jwt-sub/hooks.py|docs/conf.py|tests/e2e/$" 79 | 80 | - repo: https://github.com/pryorda/dockerfilelint-precommit-hooks 81 | rev: v0.1.0 82 | hooks: 83 | - id: dockerfilelint 84 | stages: [commit] # required 85 | 86 | - repo: https://gitlab.com/pycqa/flake8 87 | rev: 3.8.3 88 | hooks: 89 | - id: flake8 90 | 91 | - repo: https://github.com/pre-commit/pre-commit-hooks 92 | rev: v3.1.0 93 | hooks: 94 | - id: check-added-large-files 95 | - id: check-docstring-first 96 | - id: debug-statements 97 | - id: end-of-file-fixer 98 | - id: trailing-whitespace 99 | - id: check-ast 100 | - id: check-builtin-literals 101 | - id: detect-private-key 102 | - id: mixed-line-ending 103 | - id: name-tests-test 104 | args: ["--django"] 105 | exclude: "^tests/e2e/utils.py" 106 | 107 | - repo: https://github.com/pre-commit/pygrep-hooks 108 | rev: v1.5.1 109 | hooks: 110 | # - id: rst-backticks 111 | - id: python-use-type-annotations 112 | - id: python-no-log-warn 113 | - id: python-no-eval 114 | - id: python-check-mock-methods 115 | - id: python-check-blanket-noqa 116 | 117 | # commit-msg 118 | # http://jorisroovers.com/gitlint/#using-gitlint-through-pre-commit 119 | 120 | - repo: https://github.com/jorisroovers/gitlint 121 | rev: v0.13.1 122 | hooks: 123 | - id: gitlint 124 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | sudo: yes 3 | language: python 4 | python: 5 | - "3.7" 6 | services: 7 | - docker 8 | install: 9 | - pip install poetry 10 | script: 11 | - make test 12 | after_success: 13 | - coveralls 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | 3 | WORKDIR / 4 | 5 | RUN pip3 install poetry 6 | 7 | COPY poetry.lock / 8 | COPY pyproject.toml / 9 | 10 | # fake package to make Poetry happy (we will install the actual contents in the later stage) 11 | RUN mkdir /kube_web && touch /kube_web/__init__.py && touch /README.md 12 | 13 | RUN poetry config virtualenvs.create false && \ 14 | poetry install --no-interaction --no-dev --no-ansi 15 | 16 | FROM python:3.8-slim 17 | 18 | WORKDIR / 19 | 20 | # copy pre-built packages to this image 21 | COPY --from=0 /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages 22 | 23 | # now copy the actual code we will execute (poetry install above was just for dependencies) 24 | COPY kube_web /kube_web 25 | 26 | ARG VERSION=dev 27 | 28 | # replace build version in package and 29 | # add build version to static asset links to break browser cache 30 | # see also "version" in Makefile 31 | RUN sed -i "s/^__version__ = .*/__version__ = \"${VERSION}\"/" /kube_web/__init__.py && \ 32 | sed -i "s/v=[0-9A-Za-z._-]*/v=${VERSION}/g" /kube_web/templates/base.html 33 | 34 | ENTRYPOINT ["/usr/local/bin/python", "-m", "kube_web"] 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean test appjs docker push mock 2 | 3 | IMAGE ?= hjacobs/kube-web-view 4 | GITDIFFHASH = $(shell git diff | md5sum | cut -c 1-4) 5 | VERSION ?= $(shell git describe --tags --always --dirty=-dirty-$(GITDIFFHASH)) 6 | VERSIONPY = $(shell echo $(VERSION) | cut -d- -f 1) 7 | TAG ?= $(VERSION) 8 | TTYFLAGS = $(shell test -t 0 && echo "-it") 9 | OSNAME := $(shell uname | perl -ne 'print lc($$_)') 10 | 11 | default: docker 12 | 13 | .PHONY: poetry 14 | poetry: 15 | poetry install 16 | 17 | .PHONY: test 18 | test: poetry lint test.unit test.e2e 19 | 20 | .PHONY: lint 21 | lint: 22 | poetry run pre-commit run --all-files 23 | 24 | .PHONY: test.unit 25 | test.unit: 26 | poetry run coverage run --source=kube_web -m py.test tests/unit 27 | poetry run coverage report 28 | 29 | .PHONY: test.e2e 30 | test.e2e: docker 31 | env TEST_IMAGE=$(IMAGE):$(TAG) \ 32 | poetry run pytest -v -r=a \ 33 | --log-cli-level info \ 34 | --log-cli-format '%(asctime)s %(levelname)s %(message)s' \ 35 | --cluster-name kube-web-view-e2e \ 36 | tests/e2e 37 | 38 | docker: 39 | docker build --build-arg "VERSION=$(VERSION)" -t "$(IMAGE):$(TAG)" . 40 | @echo 'Docker image $(IMAGE):$(TAG) can now be used.' 41 | 42 | push: docker 43 | docker push "$(IMAGE):$(TAG)" 44 | docker tag "$(IMAGE):$(TAG)" "$(IMAGE):latest" 45 | docker push "$(IMAGE):latest" 46 | 47 | mock: 48 | docker run $(TTYFLAGS) -p 8080:8080 "$(IMAGE):$(TAG)" --mock 49 | 50 | .PHONY: docs 51 | docs: 52 | poetry run sphinx-build docs docs/_build 53 | 54 | .PHONY: run 55 | run: 56 | poetry run python3 -m kube_web --show-container-logs --debug "--object-links=ingresses=javascript:alert('{name}')" "--label-links=application=javascript:alert('Application label has value {label_value}')|eye|This is a link!" --preferred-api-versions=deployments=apps/v1 57 | 58 | .PHONY: run.kind 59 | run.kind: 60 | poetry run python3 -m kube_web --kubeconfig-context=kind-kube-web-view-e2e --debug --show-container-logs --search-default-resource-types=deployments,pods,configmaps --default-label-columns=pods=app "--default-hidden-columns=pods=Nominated Node" --exclude-namespaces=.*forbidden.* --resource-view-prerender-hook=kube_web.example_hooks.resource_view_prerender 61 | 62 | .PHONY: mirror 63 | mirror: 64 | git push --mirror git@github.com:hjacobs/kube-web-view.git 65 | 66 | .PHONY: version 67 | version: 68 | # poetry only accepts a narrow version format 69 | sed -i "s/^version = .*/version = \"${VERSIONPY}\"/" pyproject.toml 70 | sed -i "s/^version = .*/version = \"${VERSION}\"/" docs/conf.py 71 | sed -i "s/^__version__ = .*/__version__ = \"${VERSION}\"/" kube_web/__init__.py 72 | sed -i "s/v=[0-9A-Za-z._-]*/v=${VERSION}/g" kube_web/templates/base.html 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Moved to https://codeberg.org/hjacobs/kube-web-view/ 2 | -------------------------------------------------------------------------------- /deploy/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | labels: 5 | application: kube-web-view 6 | name: kube-web-view 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | application: kube-web-view 12 | template: 13 | metadata: 14 | labels: 15 | application: kube-web-view 16 | spec: 17 | serviceAccountName: kube-web-view 18 | containers: 19 | - name: kube-web-view 20 | # see https://codeberg.org/hjacobs/kube-web-view/releases 21 | image: hjacobs/kube-web-view:20.6.0 22 | args: 23 | - --port=8080 24 | # uncomment the following line to enable pod logs 25 | # (disabled by default as they might consider sensitive information) 26 | # - "--show-container-logs" 27 | # uncomment the following line to unhide secret data 28 | # see also https://kube-web-view.readthedocs.io/en/latest/security.html 29 | # - "--show-secrets" 30 | ports: 31 | - containerPort: 8080 32 | readinessProbe: 33 | httpGet: 34 | path: /health 35 | port: 8080 36 | resources: 37 | limits: 38 | memory: 100Mi 39 | requests: 40 | cpu: 5m 41 | memory: 100Mi 42 | securityContext: 43 | readOnlyRootFilesystem: true 44 | runAsNonRoot: true 45 | runAsUser: 1000 46 | -------------------------------------------------------------------------------- /deploy/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - deployment.yaml 5 | - rbac.yaml 6 | - service.yaml 7 | -------------------------------------------------------------------------------- /deploy/rbac.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: kube-web-view 6 | --- 7 | kind: ClusterRole 8 | apiVersion: rbac.authorization.k8s.io/v1beta1 9 | metadata: 10 | name: kube-web-view 11 | rules: 12 | - apiGroups: 13 | - '*' 14 | resources: 15 | - '*' 16 | verbs: [list, get] 17 | - nonResourceURLs: 18 | - '*' 19 | verbs: [list, get] 20 | --- 21 | kind: ClusterRoleBinding 22 | apiVersion: rbac.authorization.k8s.io/v1beta1 23 | metadata: 24 | name: kube-web-view 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: ClusterRole 28 | name: kube-web-view 29 | subjects: 30 | - kind: ServiceAccount 31 | name: kube-web-view 32 | namespace: default 33 | -------------------------------------------------------------------------------- /deploy/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | application: kube-web-view 6 | name: kube-web-view 7 | spec: 8 | selector: 9 | application: kube-web-view 10 | type: ClusterIP 11 | ports: 12 | - port: 80 13 | protocol: TCP 14 | targetPort: 8080 15 | -------------------------------------------------------------------------------- /docs/alternatives.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Alternative UIs 3 | =============== 4 | 5 | .. tip:: 6 | 7 | Also check out the `blog post about Kubernetes web UIs in 2019 `_ for a look at some different web UIs and why Kubernetes Web View was created. 8 | 9 | This page lists a number of alternative, open source UIs for Kubernetes. 10 | 11 | K8Dash 12 | ====== 13 | 14 | https://github.com/herbrandson/k8dash, web, node.js 15 | 16 | "K8Dash is the easiest way to manage your Kubernetes cluster." 17 | 18 | Konstellate 19 | =========== 20 | 21 | https://github.com/containership/konstellate, web, Clojure 22 | 23 | "Visualize Kubernetes Applications" 24 | 25 | Kubernetator 26 | ============ 27 | 28 | https://github.com/smpio/kubernator, web, node.js 29 | 30 | "Kubernator is an alternative Kubernetes UI. In contrast to high-level Kubernetes Dashboard, it provides low-level control and clean view on all objects in a cluster with the ability to create new ones, edit and resolve conflicts. As an entirely client-side app (like kubectl), it doesn't require any backend except Kubernetes API server itself, and also respects cluster's access control." 31 | 32 | Kubernetes Dashboard 33 | ==================== 34 | 35 | https://github.com/kubernetes/dashboard, web 36 | 37 | "Kubernetes Dashboard is a general purpose, web-based UI for Kubernetes clusters. It allows users to manage applications running in the cluster and troubleshoot them, as well as manage the cluster itself." 38 | 39 | Kubernetes Operational View 40 | =========================== 41 | 42 | https://github.com/hjacobs/kube-ops-view, web 43 | 44 | "Read-only system dashboard for multiple K8s clusters" 45 | 46 | Uses WebGL to render nodes and pods. 47 | 48 | Kubernetes Resource Report 49 | ========================== 50 | 51 | https://github.com/hjacobs/kube-resource-report/, web 52 | 53 | "Report Kubernetes cluster and pod resource requests vs usage and generate static HTML" 54 | 55 | Generates static HTML files for cost reporting. 56 | 57 | Kubevious 58 | ========= 59 | 60 | https://kubevious.io/, web 61 | 62 | "Application-centric Kubernetes viewer and validator. Correlates labels, metadata, and state. Renders configuration in a way easy to understand and debug. TimeMachine enables travel back in time to identify why things broke. Extensible. Lets users define their own validation rules in the UI." 63 | 64 | Kubricks 65 | ======== 66 | 67 | https://github.com/kubricksllc/Kubricks, desktop app 68 | 69 | "Visualizer/troubleshooting tool for single Kubernetes clusters" 70 | 71 | Octant 72 | ====== 73 | 74 | https://github.com/vmware/octant, web, Go 75 | 76 | "A web-based, highly extensible platform for developers to better understand the complexity of Kubernetes clusters." 77 | 78 | Weave Scope 79 | =========== 80 | 81 | https://github.com/weaveworks/scope, web 82 | 83 | "Monitoring, visualisation & management for Docker & Kubernetes" 84 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | # import os 12 | # import sys 13 | # sys.path.insert(0, os.path.abspath('.')) 14 | # -- Project information ----------------------------------------------------- 15 | 16 | project = "Kubernetes Web View" 17 | copyright = "2019, Henning Jacobs" 18 | author = "Henning Jacobs" 19 | version = "20.6.0" 20 | 21 | 22 | # -- General configuration --------------------------------------------------- 23 | 24 | # Add any Sphinx extension module names here, as strings. They can be 25 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 26 | # ones. 27 | extensions = [] 28 | 29 | # Add any paths that contain templates here, relative to this directory. 30 | templates_path = ["_templates"] 31 | 32 | 33 | # The master toctree document. 34 | # This setting is required for readthedocs.io 35 | master_doc = "index" 36 | 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | try: 50 | import sphinx_rtd_theme 51 | 52 | html_theme = "sphinx_rtd_theme" 53 | 54 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 55 | except Exception: 56 | pass 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named "default.css" will overwrite the builtin "default.css". 61 | html_static_path = ["_static"] 62 | -------------------------------------------------------------------------------- /docs/features.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Features 3 | ======== 4 | 5 | Multiple Clusters 6 | ================= 7 | 8 | Kubernetes Web View can access one or more clusters via different methods: 9 | 10 | * In-cluster authorization via ServiceAccount: this is the default mode when deploying kube-web-view to a single cluster 11 | * Static list of cluster API URLs passed via the ``--clusters`` CLI option, e.g. ``--clusters=myprodcluster=https://kube-prod.example.org;mytestcluster=https://kube-test.example.org`` 12 | * Clusters defined in kubeconfig file: kube-web-view will pick up all contexts defined in the kubeconfig file (``~/.kube/config`` or path given via ``--kubeconfig-path``). To only show some clusters, limit the kubeconfig contexts via the ``--kubeconfig-contexts`` command line option. 13 | * Clusters defined in a cluster registry REST API: kube-web-view supports a custom REST API to discover clusters. Pass the URL via ``--cluster-registry-url`` and create a file with the OAuth2 Bearer token (``--cluster-registry-oauth2-bearer-token-path``). See the `example Cluster Registry REST API `_. 14 | 15 | See also :ref:`multiple-clusters`. 16 | 17 | Listing Resources 18 | ================= 19 | 20 | Kubernetes Web View can list all Kubernetes resource types: 21 | 22 | * non-namespaced cluster resources under ``/clusters/{cluster}/{plural}`` 23 | * namespaced resources under ``/clusters/{cluster}/namespaces/{namespace}/{plural}`` 24 | 25 | Multiple resource types can be listed on the same page by using their comma-separated plural resource names, e.g. to list deployments and ingresses on the same page: ``/clusters/{cluster}/namespaces/{namespace}/deployments,ingresses``. 26 | Try out the `live demo with deployments and ingresses on the same page `_. 27 | 28 | To list resources across all namespaces, use ``_all`` for the namespace name in the URL. 29 | 30 | Resources can be listed across all clusters by using ``_all`` for the cluster name in the URL. 31 | 32 | Resources can be filtered by label: use the ``selector`` query parameter with label key=value pairs. 33 | 34 | To facilitate processing in spreadsheets or command line tools (``grep``, ``awk``, etc), all resource listings can be downloaded as tab-separated-values (TSV). Just append ``download=tsv`` to the URL. 35 | 36 | Columns can be customized via the ``labelcols`` and ``customcols`` query parameters: 37 | 38 | * ``labelcols`` is either a comma separated list of label names or "*" to show all labels 39 | * ``customcols`` is a semicolon-separated list of Name=spec pairs, where "Name" is an arbitrary column name string and "spec" is a `JMESPath `_ expression: e.g. ``Images=spec.containers[*].image`` would show the container images in the "Images" column. Note that the semicolon to separate multiple custom columns must be urlencoded as ``%3B``. 40 | * ``hidecols`` is a comma separated list of column names to hide or "*" to hide all columns (label and custom columns will be added after the hide operation) 41 | 42 | The ``limit`` query parameter can optionally limit the number of shown resources. 43 | 44 | Joins 45 | ----- 46 | 47 | Additional information can be "joined" to the resource list. The ``join`` query parameter allows the following two values: 48 | 49 | * When listing Pods or Nodes, ``join=metrics`` will join CPU/memory metrics to each Pod/Node. 50 | * When listing Pods, ``join=nodes`` will join the Node object to each Pod. The Node object can be accessed via ``node`` in the ``customcols`` JMESPath, e.g. ``?join=nodes&customcols=node.metadata.labels`` will add a column with all Node labels. 51 | 52 | Examples 53 | -------- 54 | 55 | * List all Nodes with their allocatable memory: ``/clusters/_all/nodes?customcols=Memory=status.allocatable.memory`` 56 | * Find all Pods which are not running and have not finished: ``/clusters/_all/namespaces/_all/pods?filter=Status!%3DRunning%2CStatus!%3DCompleted`` 57 | * Find all Pods using the privileged PodSecurityPolicy: ``/clusters/_all/namespaces/_all/pods?customcols=PSP=metadata.annotations.%22kubernetes.io/psp%22&filter=privileged`` 58 | * List all Pods and show their node's zone (e.g. AWS Availability Zone): ``/clusters/_all/namespaces/_all/pods?join=nodes&customcols=AZ=node.metadata.labels."topology.kubernetes.io/zone"`` 59 | * List all Ingresses with their custom Skipper filters: ``/clusters/_all/namespaces/_all/ingresses?customcols=Filter=metadata.annotations."zalando.org/skipper-filter"`` 60 | 61 | Searching 62 | ========= 63 | 64 | Any resource type can be searched by name and/or label value across clusters and namespaces. 65 | While Kubernetes Web View does not maintain its own search index, searches across clusters and resource types are done in parallel, so that results should be returned in a reasonable time. 66 | Please note that the search feature might produce (heavy) load on the queried Kubernetes API servers. 67 | 68 | 69 | Viewing Resources 70 | ================= 71 | 72 | Object details are available via ``/clusters/{cluster}/{resource-type}/{name}`` for cluster resources 73 | and ``/clusters/{cluster}/namespaces/{namespace}/{resource-type}/{name}`` for namespaced resources. 74 | Object details are either rendered via HTML or can be viewed as their YAML source. 75 | Resources can also be downloaded as YAML. 76 | 77 | To make it easier to point colleagues to a specific portion of a resource spec, the YAML view supports linking and highlighting individual lines. 78 | Just click on the respective line number. 79 | 80 | 81 | Container Logs 82 | ============== 83 | 84 | Kubernetes Web View supports rendering pod container logs for individual pods and any resource spec with ``matchLabels``, i.e. Deployments, ReplicaSets, DaemonSets, and StatefulSets. 85 | Just use the "Logs" tab or append ``/logs`` to the resource URL. 86 | 87 | Note that container logs are disabled by default for security reasons, enable them via ``--show-container-logs``. 88 | 89 | Custom Resource Definitions (CRDs) 90 | ================================== 91 | 92 | Kubernetes Web View automatically works for your CRDs. The list (table) view will render similar to the output of ``kubectl get ..``, 93 | i.e. you can customize displayed table columns by modifying the ``additionalPrinterColumns`` section of your CRD section. 94 | See the `official Kubernetes docs on additional printer columns `_ for details. 95 | 96 | OAuth2 97 | ====== 98 | 99 | The web frontend can be secured via the builtin OAuth2 Authorization Grant flow support, see the :ref:`oauth2` section for details. 100 | -------------------------------------------------------------------------------- /docs/getting-started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | You can find example Kubernetes manifests for deployment in the deploy folder. You need a running Kubernetes cluster (version 1.10+) and ``kubectl`` correctly configured. 6 | A local test cluster with Minikube_ or kind_ will also work. 7 | It should be as simple as: 8 | 9 | .. code-block:: bash 10 | 11 | $ git clone https://codeberg.org/hjacobs/kube-web-view 12 | $ kubectl apply -f kube-web-view/deploy 13 | 14 | Afterwards you can open "kube-web-view" via kubectl port-forward (you might need to wait a bit for the pod to become ready): 15 | 16 | .. code-block:: bash 17 | 18 | $ kubectl port-forward service/kube-web-view 8080:80 19 | 20 | Now direct your browser to http://localhost:8080/ 21 | 22 | Note that pod container logs and Kubernetes secrets are hidden by default for security reasons, 23 | you can enable them by uncommenting the respective CLI options in ``kube-web-view/deploy/deployment.yaml``. 24 | See also :ref:`security`. 25 | 26 | .. _Minikube: https://github.com/kubernetes/minikube 27 | .. _kind: https://kind.sigs.k8s.io/ 28 | 29 | For guidance on setting up Kubernetes Web View for your environment, read the :ref:`setup` section. 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Kubernetes Web View's documentation! 2 | =============================================== 3 | 4 | Kubernetes Web View allows to list and view all Kubernetes resources (incl. CRDs) with permalink-friendly URLs in a plain-HTML frontend. 5 | This tool was mainly developed to provide a web-version of kubectl for troubleshooting and supporting colleagues, see also :ref:`vision`. 6 | 7 | Git repo: https://codeberg.org/hjacobs/kube-web-view (also mirrored to https://github.com/hjacobs/kube-web-view) 8 | 9 | Live demo: https://kube-web-view.demo.j-serv.de/ 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | getting-started 16 | vision 17 | features 18 | setup 19 | oauth2 20 | customization 21 | security 22 | alternatives 23 | 24 | 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /docs/oauth2.rst: -------------------------------------------------------------------------------- 1 | .. _oauth2: 2 | 3 | ============== 4 | OAuth2 Support 5 | ============== 6 | 7 | Kubernetes Web View support OAuth2 for protecting its web frontend. Use the following environment variables to enable it: 8 | 9 | ``OAUTH2_AUTHORIZE_URL`` 10 | OAuth 2 authorization endpoint URL, e.g. https://oauth2.example.org/authorize 11 | ``OAUTH2_ACCESS_TOKEN_URL`` 12 | Token endpoint URL for the OAuth 2 Authorization Code Grant flow, e.g. https://oauth2.example.org/token 13 | ``OAUTH2_CLIENT_ID`` 14 | OAuth 2 client ID 15 | ``OAUTH2_CLIENT_ID_FILE`` 16 | Path to file containing the client ID. Use this instead of ``OAUTH2_CLIENT_ID`` to read the client ID dynamically from file. 17 | ``OAUTH2_CLIENT_SECRET`` 18 | OAuth 2 client secret 19 | ``OAUTH2_CLIENT_SECRET_FILE`` 20 | Path to file containing the client secret. Use this instead of ``OAUTH2_CLIENT_SECRET`` to read the client secret dynamically from file. 21 | ``SESSION_SECRET_KEY`` 22 | Secret to encrypt the session cookie. Must be 32 bytes base64-encoded. Use ``cryptography.fernet.Fernet.generate_key()`` to generate such a key. 23 | ``OAUTH2_SCOPE`` 24 | Scope for the OAuth 2 Authorization eg: 'email openid profile' for open ID or Azure AD, 'https://www.googleapis.com/auth/userinfo.email' 25 | Note: this field is mandatory for Azure Active Directory 26 | 27 | The OAuth2 login flow will (by default) just protect the web frontend, the configured credentials (in-cluster Service Account, Kubeconfig, or Cluster Registry) will be used to access the cluster(s). 28 | This behavior can be changed and the session's OAuth2 access token can be used for cluster authentication instead of using configured credentials. 29 | Enable this operation mode via ``--cluster-auth-use-session-token``. 30 | 31 | The OAuth redirect flow will not do any extra authorization by default, i.e. everybody who can login with your OAuth provider can use Kubernetes Web View! 32 | You can plug in a custom Python hook function (coroutine) via ``--oauth2-authorized-hook`` to validate the login or do any extra work (store extra info in the session, deny access, log user ID, etc). 33 | Note that the hook needs to be a coroutine function with signature like ``async def authorized(data, session)``. The result should be boolean true if the login is successful, and false otherwise. 34 | Examples of such hooks are provided in the `examples directory `_. A minimal ``hooks.py`` would look like: 35 | 36 | .. code-block:: python 37 | 38 | import logging 39 | 40 | async def oauth2_authorized(data: dict, session): 41 | access_token = data["access_token"] 42 | # TODO: do something with the access token, e.g. look up user info 43 | logging.info("New OAuth login!") 44 | # TODO: validate whether login is allowed or not 45 | return True # allow all OAuth logins 46 | 47 | This file would need to be in the Python search path, e.g. as ``hooks.py`` in the root ("/") of the Docker image. Pass the hook function as ``--oauth2-authorized-hook=hooks.oauth2_authorized`` to Kubernetes Web View. 48 | 49 | Google OAuth Provider 50 | ===================== 51 | 52 | This section explains how to use the Google OAuth 2.0 provider with Kubernetes Web View: 53 | 54 | * follow the instructions on https://developers.google.com/identity/protocols/OAuth2 to obtain OAuth 2.0 credentials such as client ID and client secret 55 | * use ``https://{my-kube-web-view-host}/oauth2/callback`` as one of the **Authorized redirect URIs** in the Google API Console 56 | * use "https://accounts.google.com/o/oauth2/v2/auth?scope=email" for ``OAUTH2_AUTHORIZE_URL`` 57 | * use "https://oauth2.googleapis.com/token" for ``OAUTH2_ACCESS_TOKEN_URL`` 58 | * pass the obtained client ID in the ``OAUTH2_CLIENT_ID`` environment variable 59 | * pass the obtained client secret in the ``OAUTH2_CLIENT_SECRET`` environment variable 60 | 61 | GitHub OAuth Provider 62 | ===================== 63 | 64 | How to use GitHub as the OAuth provider with Kubernetes Web View: 65 | 66 | * create a new OAuth app in the GitHub UI 67 | * use ``https://{my-kube-web-view-host}/oauth2/callback`` as the **Authorization callback URL** in the GitHub UI 68 | * use "https://github.com/login/oauth/authorize" for ``OAUTH2_AUTHORIZE_URL`` 69 | * use "https://github.com/login/oauth/access_token" for the ``OAUTH2_ACCESS_TOKEN_URL`` 70 | * pass the obtained client ID in the ``OAUTH2_CLIENT_ID`` environment variable 71 | * pass the obtained client secret in the ``OAUTH2_CLIENT_SECRET`` environment variable 72 | 73 | Note that any GitHub user can now login to your deployment of Kubernetes Web View! You have to configure a ``--oauth2-authorized-hook`` function to validate the GitHub login and only allow certain usernames: 74 | 75 | * copy ``hooks.py`` from ``examples/oauth2-validate-github-token/hooks.py`` (see `examples dir `_) to a new folder 76 | * customize the username in ``hooks.py`` to match your allowed GitHub user logins 77 | * create a new ``Dockerfile`` in the same folder 78 | * edit the ``Dockerfile`` to have two lines: 1) ``FROM hjacobs/kube-web-view:{version}`` (replace "{version}"!) as the first line, and 2) ``COPY hooks.py /`` to copy our OAuth validation function 79 | * build the Docker image 80 | * configure your kube-web-view deployment and add ``--oauth2-authorized-hook=hooks.oauth2_authorized`` as argument 81 | * deploy kube-web-view with the new Docker image and CLI option 82 | 83 | AWS Cognito Provider 84 | ===================== 85 | 86 | Setting up Cognito 87 | ------------------- 88 | 89 | A number of steps need to be taken to setup `Amazon Cognito `_ for OAuth2. These instructions are correct as of August 2019. 90 | 91 | Create User Pool 92 | ^^^^^^^^^^^^^^^^^^ 93 | 94 | 1. Create a User Pool 95 | 2. Choose how you want End Users to sign in (for example via Email, Username or otherwise) 96 | 3. Once you have gone through all the settings (customise to your liking) for creating a user pool, add an App Client 97 | 98 | Create an App Client 99 | ^^^^^^^^^^^^^^^^^^^^^ 100 | 1. Choose a Name that is relevant to the application (eg kube-web-view) 101 | 2. Make sure the **Generate client secret** option is selected, and set your **Refresh token expiration** time to whatever you are comfortable with. 102 | 103 | The App Client will then generate a Client ID and Client Secret, wich will be used later 104 | 105 | App Client Settings 106 | ^^^^^^^^^^^^^^^^^^^^ 107 | 1. Select the previously created client 108 | 2. Fill in the **Callback URL(s)** section with ``https://{my-kube-web-view-host}/oauth2/callback`` 109 | 3. Under **OAuth 2.0**, choose the relevant **Allowed OAuth Flows** (eg *Authorization Code Grant*, *Implicit Grant*) 110 | 4. Choose the **Allowed OAuth Scopes** you want to include. *email* is the minimum you will need 111 | 112 | IMPORTANT: Domain Name 113 | ^^^^^^^^^^^^^^^^^^^^^^^^ 114 | You must create a domain name for OAuth to function against AWS Cognito, otherwise the required Authorization and Token URLs will not be exposed. 115 | 116 | You can choose whether to use an AWS-hosted Cognito Domain (eg ``https://{your-chosen-domain}.auth.us-east-1.amazoncognito.com``), or to use your own domain. 117 | 118 | Update Deployment 119 | ^^^^^^^^^^^^^^^^^^^ 120 | 121 | You can now update your Deployment with the relevant Environment variables. If you have chosen to use an AWS Cognito Domain, then the ``{FQDN}`` variable in the below section will be ``https://{your-chosen-domain}.auth.{aws-region}.amazoncognito.com``. Otherwise, replace it with your domain 122 | 123 | * use "https://{FQDN}/oauth2/authorize" for ``OAUTH2_AUTHORIZE_URL`` 124 | * use "https://{FQDN}/oauth2/token" for ``OAUTH2_ACCESS_TOKEN_URL`` 125 | * Use the App Client ID generated during "Create an App Client" in the ``OAUTH2_CLIENT_ID`` environment variable 126 | * Use the App Client secret in the ``OAUTH2_CLIENT_SECRET`` environment variable. If you cannot see the secret, press "Show Details" in the AWS Console 127 | 128 | Terraform 129 | ----------- 130 | 131 | An example Terraform deployment of the above is below: 132 | 133 | .. code-block:: text 134 | 135 | # Create the User Pool 136 | resource "aws_cognito_user_pool" "kube-web-view" { 137 | name = "userpool-kube-web-view" 138 | alias_attributes = [ 139 | "email", 140 | "preferred_username" 141 | ] 142 | 143 | auto_verified_attributes = [ 144 | "email" 145 | ] 146 | 147 | schema { 148 | attribute_data_type = "String" 149 | developer_only_attribute = false 150 | mutable = true 151 | name = "name" 152 | required = true 153 | 154 | string_attribute_constraints { 155 | min_length = 3 156 | max_length = 70 157 | } 158 | } 159 | 160 | admin_create_user_config { 161 | allow_admin_create_user_only = true 162 | } 163 | 164 | tags = { 165 | "Name" = "userpool-kube-web-view" 166 | } 167 | } 168 | 169 | # Create the oauth2 Domain 170 | 171 | resource "aws_cognito_user_pool_domain" "kube-web-view" { 172 | domain = "oauth-kube-web-view" 173 | user_pool_id = aws_cognito_user_pool.kube-web-view.id 174 | } 175 | 176 | # kube-web-view Client 177 | 178 | resource "aws_cognito_user_pool_client" "kube-web-view" { 179 | name = "kube-web-view" 180 | user_pool_id = aws_cognito_user_pool.kube-web-view.id 181 | 182 | allowed_oauth_flows = [ 183 | "code", 184 | "implicit" 185 | ] 186 | 187 | allowed_oauth_scopes = [ 188 | "email", 189 | "openid", 190 | "profile", 191 | ] 192 | 193 | supported_identity_providers = [ 194 | "COGNITO" 195 | ] 196 | 197 | generate_secret = true 198 | 199 | allowed_oauth_flows_user_pool_client = true 200 | 201 | callback_urls = [ 202 | "https://{my-kube-web-view-host}/oauth2/callback" 203 | ] 204 | } 205 | 206 | 207 | # Outputs 208 | 209 | output "kube-web-view-id" { 210 | description = "Kube Web View App ID" 211 | value = aws_cognito_user_pool_client.kube-web-view.id 212 | } 213 | 214 | output "kube-web-view-secret" { 215 | description = "Kube Web View App Secret" 216 | value = aws_cognito_user_pool_client.kube-web-view.client_secret 217 | -------------------------------------------------------------------------------- /docs/security.rst: -------------------------------------------------------------------------------- 1 | .. _security: 2 | 3 | ======================= 4 | Security Considerations 5 | ======================= 6 | 7 | Kubernetes Web View exposes all Kubernetes object details via its web frontend. 8 | There are a number of security precautions to make: 9 | 10 | * Do not expose Kubernetes Web View to the public without **authorization** (e.g. OAuth2 redirect flow or some authorizing web proxy). 11 | * The default **RBAC** role for kube-web-view (provided in the ``deploy`` folder) provides **full read-only access** to the cluster --- modify it accordingly to limit the scope. 12 | * Design and **understand your access control**: decide whether you use kube-web-view only locally (with personal credentials), have a central deployment (with service credentials) to multiple clusters, use :ref:`oauth2` for cluster access via ``--cluster-auth-use-session-token``, or have it deployed per cluster with limited access. 13 | * Understand the security risks of exposing your cluster details to Kubernetes Web View users --- you should only **trust users** who you would also give full read-only access to the Kubernetes API. 14 | * Check your Kubernetes objects for **potential sensitive information**, e.g. application developers might have used container environment variables (``env``) to contain passwords (instead of using secrets or other methods), mitigate accordingly! 15 | 16 | 17 | Kubernetes Web View tries to have some sane defaults to prevent information leakage: 18 | 19 | * Pod container logs are not shown by default as they might contain sensitive information (e.g. access logs, personal data, etc). You have to enable them via the ``--show-container-logs`` command line flag. 20 | * Contents of Kubernetes secrets are masked out (hidden) by default. If you are sure that you want to show secrets (e.g. because you only run kube-web-view on your local computer (``localhost``)), you can disable this feature via the ``--show-secrets`` command line flag. 21 | 22 | Note that these are just additional features to prevent accidental security issues --- **you are responsible for securing Kubernetes Web View appropriately!** 23 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | .. _setup: 2 | 3 | ===== 4 | Setup 5 | ===== 6 | 7 | This section guides through the various options of setting up Kubernetes Web View in your environment. 8 | 9 | * Do you want to use kube-web-view as a local development/ops tool? See :ref:`local-usage` 10 | * Do you want to use it in a single cluster or access multiple clusters via kube-web-view? See :ref:`single-cluster` or :ref:`multiple-clusters`. 11 | * How do you plan to secure your setup and authenticate users? See :ref:`access-control`. 12 | * Should users see everything in your cluster(s) or only some namespaces? See :ref:`namespace-access`. 13 | * Do you want to customize behavior and look & feel for your organization? See :ref:`customization`. 14 | * Please make sure to read the :ref:`security`. 15 | 16 | 17 | .. _local-usage: 18 | 19 | Local Usage 20 | =========== 21 | 22 | Kubernetes Web View was primarily built for a (central) deployment, but you can run it locally with your existing Kubeconfig file (default location is ``~/.kube/config``). 23 | This will automatically pick up all contexts defined in Kubeconfig, i.e. works with single or multiple clusters: 24 | 25 | .. code-block:: bash 26 | 27 | docker run -it -p 8080:8080 -u $(id -u) -v $HOME/.kube:/.kube hjacobs/kube-web-view 28 | 29 | Open http://localhost:8080/ in your browser to see the UI. 30 | 31 | Note that Kubernetes Web View does not support all different proprietary authentication mechanisms (like EKS, GCP), 32 | you can use "kubectl proxy" as a workaround: 33 | 34 | .. code-block:: bash 35 | 36 | kubectl proxy --port=8001 & # start proxy in background 37 | docker run -it --net=host -u $(id -u) hjacobs/kube-web-view --clusters=local=http://localhost:8001 38 | 39 | If you are using Docker for Mac, this needs to be slightly different in order to navigate the VM/container inception: 40 | 41 | .. code-block:: bash 42 | 43 | $ kubectl proxy --accept-hosts '.*' --port=8001 & 44 | $ docker run -it -p 8080:8080 hjacobs/kube-web-view --clusters=local=http://docker.for.mac.localhost:8001 45 | 46 | Now direct your browser to http://localhost:8080 47 | 48 | 49 | .. _single-cluster: 50 | 51 | Single Cluster 52 | ============== 53 | 54 | Deploying Kubernetes Web View to a single cluster is straightforward as it will use RBAC and in-cluster ServiceAccount to talk with the Kubernetes API server: 55 | 56 | .. code-block:: bash 57 | 58 | kubectl apply -f deploy/ 59 | 60 | You can now use "kubectl port-forward service/kube-web-view 8080:80" to access the UI on http://localhost:8080/ or expose kube-web-view with a LB/Ingress. See :ref:`access-control`. 61 | 62 | 63 | .. _multiple-clusters: 64 | 65 | Multiple Clusters 66 | ================= 67 | 68 | Kubernetes Web View can access multiple clusters via different methods: 69 | 70 | * Static list of cluster API URLs passed via the ``--clusters`` CLI option, e.g. ``--clusters=myprodcluster=https://kube-prod.example.org;mytestcluster=https://kube-test.example.org`` 71 | * Clusters defined in kubeconfig file: kube-web-view will pick up all contexts defined in the kubeconfig file (``~/.kube/config`` or path given via ``--kubeconfig-path``). To only show some clusters, limit the kubeconfig contexts via the ``--kubeconfig-contexts`` command line option. This behavior is the same as for :ref:`local-usage`. 72 | * Clusters defined in a cluster registry REST API: kube-web-view supports a custom REST API to discover clusters. Pass the URL via ``--cluster-registry-url`` and create a file with the OAuth2 Bearer token (``--cluster-registry-oauth2-bearer-token-path``). See the `example Cluster Registry REST API `_. 73 | 74 | Kubernetes Web View will access the Kubernetes API differently, depending on the configuration: 75 | 76 | * when using ``--clusters``: no authentication method (or token from ``--cluster-auth-token-path``, or session token if ``--cluster-auth-use-session-token`` is set) 77 | * when using ``--kubeconfig-path``: try to use the authentication method defined in the Kubeconfig file (e.g. client certificate) 78 | * when using ``--cluster-registry-url``: use the Cluster Registry Bearer token from ``--cluster-registry-oauth2-bearer-token-path`` 79 | * when using ``--cluster-auth-token-path``: load the access token from the given file and use it as "Bearer" token for all Kubernetes API calls --- this overwrites any of the above authentication methods 80 | * when using ``--cluster-auth-use-session-token``: use the OAuth session token as "Bearer" token for the Kubernetes API --- this overwrites any other authentication method and only works when :ref:`oauth2` is enabled 81 | 82 | You can also combine the ``--clusters`` option with ``kubectl proxy`` to access clusters which have an unsupported authentication method: 83 | 84 | * start ``kubectl proxy --port=8001`` in a sidecar container 85 | * run the kube-web-view container with the ``--clusters=mycluster=http://localhost:8001`` argument 86 | 87 | You can use ``--cluster-auth-token-path`` to dynamically refresh the Bearer access token in the background. 88 | This is useful if you need to rotate the token regularly (e.g. every hour). Either run a sidecar process with a shared volume (e.g. "emptyDir") to write/refresh the token 89 | or mount a Kubernetes secret into kube-web-view's container at the given path. 90 | 91 | 92 | .. _access-control: 93 | 94 | Access Control 95 | ============== 96 | 97 | There are multiple options to secure your Kubernetes Web View deployment: 98 | 99 | * Internal service without LoadBalancer/Ingress: this requires ``kubectl port-forward service/kube-web-view 8080:80`` to access the web UI. This is the easiest option to set up (no LB/Ingress/proxy/OAuth required), but inconvenient to use. 100 | * Using a custom LB/proxy: you can expose the kube-web-view frontend through a custom proxy (e.g. nginx with ACLs, AWS ALB with authorization, etc). The setup highly depends on your environment and infrastructure. 101 | * Using the built-in OAuth support: kube-web-view has support for the authorization grant OAuth redirect flow which works with common OAuth providers such as Google, GitHub, Cognito, and others. See :ref:`oauth2` on how to configure OAuth in Kubernetes Web View. 102 | 103 | .. _namespace-access: 104 | 105 | Namespace Access 106 | ================ 107 | 108 | Kubernetes Web View allows to limit namespace access with include and exclude patterns, examples: 109 | 110 | * use ``--include-namespaces=default,dev`` to only allow access to the "default" and "dev" namespaces 111 | * use ``--exclude-namespaces=kube-.*`` to deny access to all "kube-.*" (system) namespaces 112 | 113 | Users can still access the "_all" namespaced resource lists and search across namespaces, but objects for non-allowed namespaces will be filtered out. 114 | You can use this feature to give users a more streamlined experience by hiding infrastructure namespaces (e.g. "kube-system") from them. 115 | 116 | Note that ``--exclude-namespaces`` always takes precedence over ``--include-namespaces``, i.e. you can include all "foo-.*" namespaces (``--include-namespaces=foo-.*``) and exclude only "foo-bar" via (``--exclude-namespaces=foo-bar``). 117 | 118 | Please use Kubernetes RBAC roles for proper access control, kube-web-view's namespace filtering is just another layer of protection. Please also read the :ref:`security`. 119 | -------------------------------------------------------------------------------- /docs/vision.rst: -------------------------------------------------------------------------------- 1 | .. _vision: 2 | 3 | ============== 4 | Vision & Goals 5 | ============== 6 | 7 | *"kubectl for the web!"* 8 | 9 | Kubernetes Web View's goal is to provide a no-frills HTML frontend for listing and inspecting K8s objects in troubleshooting and incident response scenarios. 10 | 11 | The main audience of Kubernetes Web View is experienced "power" users, on-call/SREs, and cluster operators. 12 | Understanding Kubernetes concepts and resources is expected. 13 | 14 | The focus on troubleshooting and "kubectl on the web" led to the following design principles and goals: 15 | 16 | * enable all (read-only) operations where people commonly use ``kubectl`` as their tool of choice 17 | * all URLs should represent the full view state (permalinks) in order to make them shareable among colleagues and facilitate deep-linking from other tools 18 | * all Kubernetes objects should be supported to be able to troubleshoot any kind of problem 19 | * resource lists should be easily downloadable for further processing (spreadsheet, CLI tools like ``grep``) and storage (e.g. for postmortems) 20 | * selecting resources by label (similar to ``kubectl get .. -l``) should be supported 21 | * composing views of different resource types should be possible (similar to ``kubectl get all``) to provide a common operational picture among colleagues (e.g. during incident response) 22 | * adding custom "smart" deep links to other tools such as monitoring dashboards, logging providers, application registries, etc should be possible to facilitate troubleshooting and incident response 23 | * keep the frontend as simple as possible (pure HTML) to avoid accidental problems, e.g. unresponsive JavaScript 24 | * support multiple clusters to streamline discovery in on-call situations (only one entry URL to remember) 25 | * facilitate ad-hoc analysis where possible (e.g. with download links for resources across clusters/namespaces) 26 | * provide additional deep-linking and highlighting, e.g. to point colleagues to a certain part of a resource spec (line in YAML) 27 | * allow customization for org-specific optimizations: e.g. custom view templates for CRDs, custom table views, custom CSS formatting 28 | * provide means to continue investigation on the command line (e.g. by showing full ``kubectl`` command lines to copy) 29 | 30 | Out-of-scope (non-goals) for Kubernetes Web View are: 31 | 32 | * abstracting Kubernetes objects 33 | * application management (e.g. managing deployments, Helm Charts, etc) 34 | * write operations (this should be done via safe CI/CD tooling and/or GitOps) 35 | * fancy UI (JavaScript, theming, etc) 36 | * visualization (check out `kube-ops-view `_) 37 | * cost analysis (check out `kube-resource-report `_) 38 | -------------------------------------------------------------------------------- /examples/cluster-registry/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine3.10 2 | 3 | WORKDIR / 4 | 5 | RUN pip3 install aiohttp 6 | 7 | COPY cluster-registry.py / 8 | 9 | ENTRYPOINT ["/usr/local/bin/python", "/cluster-registry.py"] 10 | -------------------------------------------------------------------------------- /examples/cluster-registry/README.md: -------------------------------------------------------------------------------- 1 | # Example Cluster Registry 2 | 3 | ``` 4 | docker build -t cluster-registry . 5 | docker run -it -p 8081:8081 cluster-registry 6 | curl http://localhost:8081/kubernetes-clusters 7 | ``` 8 | -------------------------------------------------------------------------------- /examples/cluster-registry/cluster-registry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example implementation of REST endpoint for a Cluster Registry. 4 | 5 | To be used with --cluster-registry-url option 6 | """ 7 | from aiohttp import web 8 | 9 | KUBERNETES_CLUSTERS = [ 10 | { 11 | "id": "123", 12 | "alias": "foo", 13 | "api_server_url": "https://cluster-123.example.org", 14 | "channel": "stable", 15 | "environment": "production", 16 | "infrastructure_account": "aws:123456789012", 17 | "region": "eu-central-1", 18 | "lifecycle_status": "ready", 19 | }, 20 | { 21 | "id": "123", 22 | "alias": "bar", 23 | "api_server_url": "https://cluster-456.example.org", 24 | "channel": "beta", 25 | "environment": "test", 26 | "infrastructure_account": "aws:123456789012", 27 | "region": "eu-central-1", 28 | "lifecycle_status": "ready", 29 | }, 30 | ] 31 | 32 | routes = web.RouteTableDef() 33 | 34 | 35 | @routes.get("/kubernetes-clusters") 36 | async def get_clusters(request): 37 | return web.json_response({"items": KUBERNETES_CLUSTERS}) 38 | 39 | 40 | if __name__ == "__main__": 41 | app = web.Application() 42 | app.add_routes(routes) 43 | web.run_app(app, port=8081) 44 | -------------------------------------------------------------------------------- /examples/oauth2-log-jwt-sub/hooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example hook to parse a JWT access token and log the "sub" claim. 3 | 4 | To be used with --oauth2-authorized-hook option 5 | 6 | See also https://kube-web-view.readthedocs.io/en/latest/oauth2.html 7 | """ 8 | import base64 9 | import json 10 | import logging 11 | 12 | CLAIM_NAME = "sub" 13 | 14 | 15 | async def oauth2_authorized(data: dict, session): 16 | # note: you could also use "id_token" if your OAuth provider returns it 17 | token = data["access_token"] 18 | header, payload, signature = token.split(".") 19 | # we don't need to verify the signature as the token comes fresh from OAuth provider 20 | decoded = base64.b64decode(payload + "=" * ((4 - len(payload) % 4) % 4)).decode( 21 | "utf-8" 22 | ) 23 | 24 | payload_data = json.loads(decoded) 25 | 26 | user = payload_data[CLAIM_NAME] 27 | logging.info(f"User {user} was logged in") 28 | # allow login 29 | return True 30 | -------------------------------------------------------------------------------- /examples/oauth2-validate-github-token/hooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example hook to validate GitHub user login. 3 | 4 | To be used with --oauth2-authorized-hook option 5 | 6 | See also https://kube-web-view.readthedocs.io/en/latest/oauth2.html 7 | """ 8 | import logging 9 | 10 | import aiohttp 11 | 12 | 13 | # list of authorized GitHub usernames 14 | AUTHORIZED_USERS = frozenset(["hjacobs"]) 15 | 16 | 17 | async def oauth2_authorized(data: dict, session): 18 | token = data["access_token"] 19 | async with aiohttp.ClientSession() as session: 20 | async with session.get( 21 | "https://api.github.com/user", headers={"Authorization": f"token {token}"} 22 | ) as resp: 23 | user_info = await resp.json() 24 | login = user_info["login"] 25 | logging.info(f"GitHub login is {login}") 26 | if login not in AUTHORIZED_USERS: 27 | # not authorized to access this app! 28 | return False 29 | return True 30 | -------------------------------------------------------------------------------- /kube_web/__init__.py: -------------------------------------------------------------------------------- 1 | # This version is replaced during release process. 2 | __version__ = "20.6.0" 3 | -------------------------------------------------------------------------------- /kube_web/__main__.py: -------------------------------------------------------------------------------- 1 | from .main import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /kube_web/cluster_discovery.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from pathlib import Path 4 | from typing import List 5 | from urllib.parse import urljoin 6 | 7 | import requests 8 | from pykube import HTTPClient 9 | from pykube import KubeConfig 10 | from requests.auth import AuthBase 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class OAuth2BearerTokenAuth(AuthBase): 17 | 18 | """Dynamic authentication loading OAuth Bearer token from file (potentially mounted from a Kubernetes secret).""" 19 | 20 | def __init__(self, token_path: Path): 21 | self.token_path = token_path 22 | 23 | def __call__(self, request): 24 | if "Authorization" in request.headers: 25 | # do not overwrite any existing Authorization header 26 | return request 27 | with self.token_path.open() as fd: 28 | token = fd.read().strip() 29 | request.headers["Authorization"] = f"Bearer {token}" 30 | return request 31 | 32 | 33 | class Cluster: 34 | def __init__( 35 | self, name: str, api: HTTPClient, labels: dict = None, spec: dict = None 36 | ): 37 | self.name = name 38 | self.api = api 39 | self.labels = labels or {} 40 | self.spec = spec or {} 41 | 42 | 43 | class StaticClusterDiscoverer: 44 | def __init__(self, clusters: dict): 45 | self._clusters = [] 46 | 47 | for cluster_name, url in clusters.items(): 48 | config = KubeConfig.from_url(url) 49 | client = HTTPClient(config) 50 | cluster = Cluster(cluster_name, client) 51 | self._clusters.append(cluster) 52 | 53 | def get_clusters(self): 54 | return self._clusters 55 | 56 | 57 | class ServiceAccountNotFound(Exception): 58 | pass 59 | 60 | 61 | class ServiceAccountClusterDiscoverer: 62 | def __init__(self): 63 | self._clusters = [] 64 | 65 | try: 66 | config = KubeConfig.from_service_account() 67 | except FileNotFoundError: 68 | # we are not running inside a cluster 69 | raise ServiceAccountNotFound() 70 | 71 | client = HTTPClient(config) 72 | cluster = Cluster("local", client) 73 | self._clusters.append(cluster) 74 | 75 | def get_clusters(self): 76 | return self._clusters 77 | 78 | 79 | class ClusterRegistryDiscoverer: 80 | def __init__( 81 | self, 82 | cluster_registry_url: str, 83 | oauth2_bearer_token_path: Path, 84 | cache_lifetime=60, 85 | ): 86 | self._url = cluster_registry_url 87 | self._oauth2_bearer_token_path = oauth2_bearer_token_path 88 | self._cache_lifetime = cache_lifetime 89 | self._last_cache_refresh = 0 90 | self._clusters: List[Cluster] = [] 91 | self._session = requests.Session() 92 | if self._oauth2_bearer_token_path: 93 | self._session.auth = OAuth2BearerTokenAuth(self._oauth2_bearer_token_path) 94 | 95 | def refresh(self): 96 | try: 97 | response = self._session.get( 98 | urljoin(self._url, "/kubernetes-clusters"), timeout=10 99 | ) 100 | response.raise_for_status() 101 | clusters = [] 102 | for row in response.json()["items"]: 103 | # only consider "ready" clusters 104 | if row.get("lifecycle_status", "ready") == "ready": 105 | config = KubeConfig.from_url(row["api_server_url"]) 106 | client = HTTPClient(config) 107 | client.session.auth = OAuth2BearerTokenAuth( 108 | self._oauth2_bearer_token_path 109 | ) 110 | labels = {} 111 | for key in ( 112 | "id", 113 | "channel", 114 | "environment", 115 | "infrastructure_account", 116 | "region", 117 | ): 118 | if key in row: 119 | labels[key.replace("_", "-")] = row[key] 120 | clusters.append(Cluster(row["alias"], client, labels, row)) 121 | self._clusters = clusters 122 | self._last_cache_refresh = time.time() 123 | except Exception: 124 | logger.exception(f"Failed to refresh from cluster registry {self._url}") 125 | 126 | def get_clusters(self): 127 | now = time.time() 128 | if now - self._last_cache_refresh > self._cache_lifetime: 129 | self.refresh() 130 | return self._clusters 131 | 132 | 133 | class KubeconfigDiscoverer: 134 | def __init__(self, kubeconfig_path: Path, contexts: set): 135 | self._path = kubeconfig_path 136 | self._contexts = contexts 137 | 138 | def get_clusters(self): 139 | # Kubernetes Python client expects "vintage" string path 140 | config_file = str(self._path) if self._path else None 141 | config = KubeConfig.from_file(config_file) 142 | for context in config.contexts: 143 | if self._contexts and context not in self._contexts: 144 | # filter out 145 | continue 146 | # create a new KubeConfig with new "current context" 147 | context_config = KubeConfig(config.doc, context) 148 | client = HTTPClient(context_config) 149 | cluster = Cluster(context, client) 150 | yield cluster 151 | 152 | 153 | class MockDiscoverer: 154 | def get_clusters(self): 155 | for i in range(3): 156 | yield Cluster(f"mock-cluster-{i}", client=None) 157 | -------------------------------------------------------------------------------- /kube_web/cluster_manager.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Dict 4 | from typing import List 5 | 6 | from .cluster_discovery import OAuth2BearerTokenAuth 7 | from .resource_registry import ResourceRegistry 8 | from .selector import selector_matches 9 | 10 | INVALID_CLUSTER_NAME_CHAR_PATTERN = re.compile("[^a-zA-Z0-9:_.-]") 11 | 12 | 13 | def sanitize_cluster_name(name: str): 14 | """Replace all invalid characters with a colon (":").""" 15 | return INVALID_CLUSTER_NAME_CHAR_PATTERN.sub(":", name) 16 | 17 | 18 | class Cluster: 19 | def __init__( 20 | self, 21 | name: str, 22 | api, 23 | labels: dict, 24 | spec: dict, 25 | resource_registry: ResourceRegistry, 26 | ): 27 | self.name = name 28 | self.api = api 29 | self.labels = labels or {} 30 | self.spec = spec or {} 31 | self.resource_registry = resource_registry 32 | 33 | 34 | class ClusterNotFound(Exception): 35 | def __init__(self, cluster): 36 | self.cluster = cluster 37 | 38 | 39 | class ClusterManager: 40 | def __init__( 41 | self, 42 | discoverer, 43 | selector: dict, 44 | cluster_auth_token_path: Path, 45 | preferred_api_versions: dict, 46 | ): 47 | self._clusters: Dict[str, Cluster] = {} 48 | self.discoverer = discoverer 49 | self.selector = selector 50 | self.cluster_auth_token_path = cluster_auth_token_path 51 | self.preferred_api_versions = preferred_api_versions 52 | self.reload() 53 | 54 | def reload(self): 55 | _clusters = {} 56 | for cluster in self.discoverer.get_clusters(): 57 | if selector_matches(self.selector, cluster.labels): 58 | if self.cluster_auth_token_path: 59 | # overwrite auth mechanism with dynamic access token (loaded from file) 60 | cluster.api.session.auth = OAuth2BearerTokenAuth( 61 | self.cluster_auth_token_path 62 | ) 63 | # the cluster name might contain invalid characters, 64 | # e.g. KubeConfig context names can contain slashes 65 | sanitized_name = sanitize_cluster_name(cluster.name) 66 | previous_cluster = self._clusters.get(sanitized_name) 67 | if previous_cluster: 68 | # the Resource Registry (registered APIs, CRDs, ..) takes a long time to load, 69 | # we therefore want to keep the information even when reloading the cluster list 70 | resource_registry = previous_cluster.resource_registry 71 | else: 72 | resource_registry = ResourceRegistry( 73 | cluster.api, self.preferred_api_versions 74 | ) 75 | _clusters[sanitized_name] = Cluster( 76 | sanitized_name, 77 | cluster.api, 78 | cluster.labels, 79 | cluster.spec, 80 | resource_registry, 81 | ) 82 | 83 | self._clusters = _clusters 84 | 85 | @property 86 | def clusters(self) -> List[Cluster]: 87 | self.reload() 88 | return list(self._clusters.values()) 89 | 90 | def get(self, cluster: str) -> Cluster: 91 | obj = self._clusters.get(cluster) 92 | if not obj: 93 | raise ClusterNotFound(cluster) 94 | return obj 95 | -------------------------------------------------------------------------------- /kube_web/example_hooks.py: -------------------------------------------------------------------------------- 1 | """Define example hook functions for Kubernetes Web View.""" 2 | 3 | 4 | async def resource_view_prerender(cluster, namespace, resource, context): 5 | """ 6 | Example hook function for the resource view page. Adds a link (icon button) for deployments. 7 | 8 | Usage: --resource-view-prerender-hook=kube_web.example_hooks.resource_view_prerender 9 | """ 10 | if resource.kind == "Deployment": 11 | link = { 12 | "href": f"#this-is-a-custom-link;name={resource.name}", 13 | "class": "is-link", 14 | "title": "Some example link to nowhere", 15 | "icon": "external-link-alt", 16 | } 17 | context["links"].append(link) 18 | -------------------------------------------------------------------------------- /kube_web/jinja2_filters.py: -------------------------------------------------------------------------------- 1 | import colorsys 2 | import datetime 3 | 4 | import pygments 5 | import yaml as pyyaml 6 | from pygments.formatters import HtmlFormatter 7 | from pygments.lexers import get_lexer_by_name 8 | 9 | 10 | def pluralize(singular): 11 | if singular.endswith("s"): 12 | # Ingress -> Ingresses 13 | return singular + "es" 14 | elif singular.endswith("y"): 15 | # NetworkPolicy -> NetworkPolicies 16 | return singular[:-1] + "ies" 17 | else: 18 | return singular + "s" 19 | 20 | 21 | def yaml(value): 22 | return pyyaml.dump(value, default_flow_style=False) 23 | 24 | 25 | def highlight(value, linenos=False): 26 | 27 | if linenos: 28 | formatter = HtmlFormatter( 29 | lineanchors="line", 30 | anchorlinenos=True, 31 | linenos="table", 32 | linespans="yaml-line", 33 | ) 34 | else: 35 | formatter = HtmlFormatter() 36 | 37 | return pygments.highlight(value, get_lexer_by_name("yaml"), formatter) 38 | 39 | 40 | def age_color(date_time, days=7, hue=0.39, value=0.21): 41 | """Return HTML color calculated by age of input time value. 42 | 43 | :param d: datetime value to base color calculation on 44 | :param days: upper limit for color calculation, in days 45 | :return: HTML color value string 46 | """ 47 | 48 | if not date_time: 49 | return "auto" 50 | if isinstance(date_time, str): 51 | date_time = datetime.datetime.strptime(date_time, "%Y-%m-%dT%H:%M:%SZ") 52 | d = datetime.datetime.utcnow() - date_time 53 | # we consider the last minute equal 54 | d = max(0, d.total_seconds() - 60) 55 | s = max(0, 1.0 - d / (days * 24.0 * 3600)) 56 | # dates older than days are color #363636 (rgb(54, 54, 54)) 57 | r, g, b = colorsys.hsv_to_rgb(hue, s, value + (s * (0.81 - value))) 58 | return ( 59 | f"#{int(round(r * 255)):02x}{int(round(g * 255)):02x}{int(round(b * 255)):02x}" 60 | ) 61 | 62 | 63 | def cpu(value): 64 | return "{:,.0f}m".format(value * 1000) 65 | 66 | 67 | def memory(value, fmt): 68 | if fmt == "GiB": 69 | return "{:,.01f}".format(value / (1024 ** 3)) 70 | elif fmt == "MiB": 71 | return "{:,.0f}".format(value / (1024 ** 2)) 72 | else: 73 | return value 74 | -------------------------------------------------------------------------------- /kube_web/joins.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import logging 3 | import re 4 | from typing import Dict 5 | 6 | import jmespath 7 | import pykube 8 | from pykube.objects import NamespacedAPIObject 9 | from pykube.objects import Node 10 | from pykube.objects import Pod 11 | 12 | from kube_web import kubernetes 13 | from kube_web import query_params as qp 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | NON_WORD_CHARS = re.compile("[^0-9a-zA-Z]+") 18 | SECRET_CONTENT_HIDDEN = "**SECRET-CONTENT-HIDDEN-BY-KUBE-WEB-VIEW**" 19 | 20 | 21 | def generate_name_from_spec(spec: str) -> str: 22 | words = NON_WORD_CHARS.split(spec) 23 | name = " ".join([word.capitalize() for word in words if word]) 24 | return name 25 | 26 | 27 | async def join_metrics( 28 | wrap_query, _cluster, table, namespace: str, is_all_namespaces: bool, params: dict, 29 | ): 30 | if not table.rows: 31 | # nothing to do 32 | return 33 | 34 | table.columns.append({"name": "CPU Usage"}) 35 | table.columns.append({"name": "Memory Usage"}) 36 | 37 | if table.api_obj_class.kind == Pod.kind: 38 | clazz = kubernetes.PodMetrics 39 | elif table.api_obj_class.kind == Node.kind: 40 | clazz = kubernetes.NodeMetrics 41 | 42 | row_index_by_namespace_name = {} 43 | for i, row in enumerate(table.rows): 44 | row_index_by_namespace_name[ 45 | ( 46 | row["object"]["metadata"].get("namespace"), 47 | row["object"]["metadata"]["name"], 48 | ) 49 | ] = i 50 | 51 | query = wrap_query(clazz.objects(_cluster.api)) 52 | 53 | if issubclass(clazz, NamespacedAPIObject): 54 | if is_all_namespaces: 55 | query = query.filter(namespace=pykube.all) 56 | elif namespace: 57 | query = query.filter(namespace=namespace) 58 | 59 | if params.get(qp.SELECTOR): 60 | query = query.filter(selector=params[qp.SELECTOR]) 61 | 62 | rows_joined = set() 63 | 64 | try: 65 | metrics_list = await kubernetes.get_list(query) 66 | except Exception as e: 67 | logger.warning(f"Failed to query {clazz.kind} in cluster {_cluster.name}: {e}") 68 | else: 69 | for metrics in metrics_list: 70 | key = (metrics.namespace, metrics.name) 71 | row_index = row_index_by_namespace_name.get(key) 72 | if row_index is not None: 73 | usage: Dict[str, float] = collections.defaultdict(float) 74 | if "containers" in metrics.obj: 75 | for container in metrics.obj["containers"]: 76 | for k, v in container.get("usage", {}).items(): 77 | usage[k] += kubernetes.parse_resource(v) 78 | else: 79 | for k, v in metrics.obj.get("usage", {}).items(): 80 | usage[k] += kubernetes.parse_resource(v) 81 | 82 | table.rows[row_index]["cells"].extend( 83 | [usage.get("cpu", 0), usage.get("memory", 0)] 84 | ) 85 | rows_joined.add(row_index) 86 | 87 | # fill up cells where we have no metrics 88 | for i, row in enumerate(table.rows): 89 | if i not in rows_joined: 90 | # use zero instead of None to allow sorting 91 | row["cells"].extend([0, 0]) 92 | 93 | 94 | async def join_custom_columns( 95 | wrap_query, 96 | _cluster, 97 | table, 98 | namespace: str, 99 | is_all_namespaces: bool, 100 | custom_columns_param: str, 101 | params: dict, 102 | config, 103 | ): 104 | if not table.rows: 105 | # nothing to do 106 | return 107 | 108 | clazz = table.api_obj_class 109 | 110 | custom_column_names = [] 111 | custom_columns = {} 112 | for part in filter(None, custom_columns_param.split(";")): 113 | name, _, spec = part.partition("=") 114 | if not spec: 115 | spec = name 116 | name = generate_name_from_spec(spec) 117 | custom_column_names.append(name) 118 | custom_columns[name] = jmespath.compile(spec) 119 | 120 | if not custom_columns: 121 | # nothing to do 122 | return 123 | 124 | for name in custom_column_names: 125 | table.columns.append({"name": name}) 126 | 127 | row_index_by_namespace_name = {} 128 | for i, row in enumerate(table.rows): 129 | row_index_by_namespace_name[ 130 | ( 131 | row["object"]["metadata"].get("namespace"), 132 | row["object"]["metadata"]["name"], 133 | ) 134 | ] = i 135 | 136 | nodes = None 137 | if params.get(qp.JOIN) == "nodes" and clazz.kind == Pod.kind: 138 | node_query = wrap_query(Node.objects(_cluster.api)) 139 | try: 140 | node_list = await kubernetes.get_list(node_query) 141 | except Exception as e: 142 | logger.warning( 143 | f"Failed to query {Node.kind} in cluster {_cluster.name}: {e}" 144 | ) 145 | else: 146 | nodes = {} 147 | for node in node_list: 148 | nodes[node.name] = node 149 | 150 | query = wrap_query(clazz.objects(_cluster.api)) 151 | 152 | if issubclass(clazz, NamespacedAPIObject): 153 | if is_all_namespaces: 154 | query = query.filter(namespace=pykube.all) 155 | elif namespace: 156 | query = query.filter(namespace=namespace) 157 | 158 | if params.get(qp.SELECTOR): 159 | query = query.filter(selector=params[qp.SELECTOR]) 160 | 161 | rows_joined = set() 162 | 163 | try: 164 | object_list = await kubernetes.get_list(query) 165 | except Exception as e: 166 | logger.warning(f"Failed to query {clazz.kind} in cluster {_cluster.name}: {e}") 167 | else: 168 | for obj in object_list: 169 | key = (obj.namespace, obj.name) 170 | row_index = row_index_by_namespace_name.get(key) 171 | if row_index is not None: 172 | for name in custom_column_names: 173 | expression = custom_columns[name] 174 | if clazz.kind == "Secret" and not config.show_secrets: 175 | value = SECRET_CONTENT_HIDDEN 176 | else: 177 | if nodes: 178 | node = nodes.get(obj.obj["spec"].get("nodeName")) 179 | data = {"node": node and node.obj} 180 | data.update(obj.obj) 181 | else: 182 | data = obj.obj 183 | value = expression.search(data) 184 | table.rows[row_index]["cells"].append(value) 185 | rows_joined.add(row_index) 186 | 187 | # fill up cells where we have no values 188 | for i, row in enumerate(table.rows): 189 | if i not in rows_joined: 190 | row["cells"].extend([None] * len(custom_column_names)) 191 | -------------------------------------------------------------------------------- /kube_web/kubernetes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import re 4 | from functools import partial 5 | 6 | from pykube.http import HTTPClient 7 | from pykube.objects import APIObject 8 | from pykube.objects import NamespacedAPIObject 9 | from pykube.objects import Pod 10 | from pykube.query import Query 11 | 12 | FACTORS = { 13 | "n": 1 / 1000000000, 14 | "u": 1 / 1000000, 15 | "m": 1 / 1000, 16 | "": 1, 17 | "k": 1000, 18 | "M": 1000 ** 2, 19 | "G": 1000 ** 3, 20 | "T": 1000 ** 4, 21 | "P": 1000 ** 5, 22 | "E": 1000 ** 6, 23 | "Ki": 1024, 24 | "Mi": 1024 ** 2, 25 | "Gi": 1024 ** 3, 26 | "Ti": 1024 ** 4, 27 | "Pi": 1024 ** 5, 28 | "Ei": 1024 ** 6, 29 | } 30 | 31 | RESOURCE_PATTERN = re.compile(r"^(\d*)(\D*)$") 32 | 33 | thread_pool = concurrent.futures.ThreadPoolExecutor(thread_name_prefix="pykube") 34 | 35 | 36 | # https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md 37 | class NodeMetrics(APIObject): 38 | 39 | version = "metrics.k8s.io/v1beta1" 40 | endpoint = "nodes" 41 | kind = "NodeMetrics" 42 | 43 | 44 | # https://github.com/kubernetes/community/blob/master/contributors/design-proposals/instrumentation/resource-metrics-api.md 45 | class PodMetrics(NamespacedAPIObject): 46 | 47 | version = "metrics.k8s.io/v1beta1" 48 | endpoint = "pods" 49 | kind = "PodMetrics" 50 | 51 | 52 | def parse_resource(v): 53 | """ 54 | Parse a Kubernetes resource spec. 55 | 56 | >>> parse_resource('100m') 57 | 0.1 58 | >>> parse_resource('100M') 59 | 1000000000 60 | >>> parse_resource('2Gi') 61 | 2147483648 62 | >>> parse_resource('2k') 63 | 2048 64 | """ 65 | match = RESOURCE_PATTERN.match(v) 66 | factor = FACTORS[match.group(2)] 67 | return int(match.group(1)) * factor 68 | 69 | 70 | async def api_get(api, **kwargs): 71 | loop = asyncio.get_event_loop() 72 | return await loop.run_in_executor( 73 | thread_pool, partial(HTTPClient.get, **kwargs), api 74 | ) 75 | 76 | 77 | async def get_by_name(query: Query, name: str): 78 | loop = asyncio.get_event_loop() 79 | return await loop.run_in_executor(thread_pool, Query.get_by_name, query, name) 80 | 81 | 82 | async def get_table(query: Query): 83 | loop = asyncio.get_event_loop() 84 | return await loop.run_in_executor(thread_pool, Query.as_table, query) 85 | 86 | 87 | def _get_list(query: Query): 88 | return list(query.iterator()) 89 | 90 | 91 | async def get_list(query: Query): 92 | loop = asyncio.get_event_loop() 93 | return await loop.run_in_executor(thread_pool, _get_list, query) 94 | 95 | 96 | async def logs(pod: Pod, **kwargs): 97 | loop = asyncio.get_event_loop() 98 | pod_logs = partial(Pod.logs, **kwargs) 99 | return await loop.run_in_executor(thread_pool, pod_logs, pod) 100 | -------------------------------------------------------------------------------- /kube_web/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import collections 4 | import importlib 5 | import logging 6 | import re 7 | from pathlib import Path 8 | 9 | import aiohttp.web 10 | 11 | from .cluster_discovery import ClusterRegistryDiscoverer 12 | from .cluster_discovery import KubeconfigDiscoverer 13 | from .cluster_discovery import ServiceAccountClusterDiscoverer 14 | from .cluster_discovery import ServiceAccountNotFound 15 | from .cluster_discovery import StaticClusterDiscoverer 16 | from .cluster_manager import ClusterManager 17 | from .selector import parse_selector 18 | from .web import get_app 19 | from kube_web import __version__ 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def comma_separated_values(value): 26 | return list(filter(None, value.split(","))) 27 | 28 | 29 | def comma_separated_patterns(value): 30 | return list(re.compile(p) for p in filter(None, value.split(","))) 31 | 32 | 33 | def key_value_pairs(value): 34 | data = {} 35 | for kv_pair in value.split(";"): 36 | key, sep, value = kv_pair.partition("=") 37 | data[key] = value 38 | return data 39 | 40 | 41 | def key_value_pairs2(value): 42 | data = {} 43 | for kv_pair in value.split(";;"): 44 | key, sep, value = kv_pair.partition("=") 45 | data[key] = value 46 | return data 47 | 48 | 49 | def key_value_list_pairs(value): 50 | data = {} 51 | for kv_pair in value.split(";"): 52 | key, sep, value = kv_pair.partition("=") 53 | data[key] = comma_separated_values(value) 54 | return data 55 | 56 | 57 | def coroutine_function(value): 58 | module_name, attr_path = value.rsplit(".", 1) 59 | module = importlib.import_module(module_name) 60 | function = getattr(module, attr_path) 61 | if not asyncio.iscoroutinefunction(function): 62 | raise ValueError(f"Not a coroutine (async) function: {value}") 63 | return function 64 | 65 | 66 | def links_dict(value): 67 | links = collections.defaultdict(list) 68 | if value: 69 | for link_def in value.split(","): 70 | key, sep, url_template = link_def.partition("=") 71 | url_template, *options = url_template.split("|") 72 | icon, title, *rest = options + [None, None] 73 | links[key].append( 74 | { 75 | "href": url_template, 76 | "icon": icon or "external-link-alt", 77 | "title": title or "External link", 78 | } 79 | ) 80 | return links 81 | 82 | 83 | def parse_args(argv=None): 84 | 85 | parser = argparse.ArgumentParser(description=f"Kubernetes Web View v{__version__}") 86 | parser.add_argument( 87 | "--port", 88 | type=int, 89 | default=8080, 90 | help="TCP port to start webserver on (default: 8080)", 91 | ) 92 | parser.add_argument( 93 | "--version", action="version", version=f"kube-web-view {__version__}" 94 | ) 95 | parser.add_argument( 96 | "--include-namespaces", 97 | type=comma_separated_patterns, 98 | help="List of namespaces to allow access to (default: all namespaces). Can be a comma-separated list of regex patterns.", 99 | ) 100 | parser.add_argument( 101 | "--exclude-namespaces", 102 | type=comma_separated_patterns, 103 | help="List of namespaces to deny access to (default: none). Can be a comma-separated list of regex patterns.", 104 | ) 105 | parser.add_argument( 106 | "--clusters", 107 | type=key_value_pairs, 108 | help="Cluster NAME=URL pairs separated by semicolon, e.g. 'foo=https://foo-api.example.org;bar=https://localhost:6443'", 109 | ) 110 | parser.add_argument("--kubeconfig-path", help="Path to ~/.kube/config file") 111 | parser.add_argument( 112 | "--kubeconfig-contexts", 113 | type=comma_separated_values, 114 | help="List of kubeconfig contexts to use (default: use all defined contexts)", 115 | ) 116 | parser.add_argument("--cluster-registry-url", help="URL to cluster registry") 117 | parser.add_argument( 118 | "--cluster-registry-oauth2-bearer-token-path", 119 | type=Path, 120 | help="Path to OAuth2 Bearer token for Cluster Registry authentication", 121 | ) 122 | parser.add_argument( 123 | "--cluster-label-selector", 124 | type=parse_selector, 125 | help="Optional label selector to filter clusters, e.g. 'region=eu-central-1' would only load clusters with label 'region' equal 'eu-central-1'", 126 | ) 127 | parser.add_argument( 128 | "--cluster-auth-token-path", 129 | type=Path, 130 | help="Path to file containing OAuth2 access Bearer token to use for cluster authentication", 131 | ) 132 | parser.add_argument( 133 | "--cluster-auth-use-session-token", 134 | action="store_true", 135 | help="Use OAuth2 access token from frontend session for cluster authentication", 136 | ) 137 | parser.add_argument( 138 | "--show-container-logs", 139 | action="store_true", 140 | help="Enable container logs (hidden by default as they can contain sensitive information)", 141 | ) 142 | parser.add_argument( 143 | "--show-secrets", 144 | action="store_true", 145 | help="Show contents of Kubernetes Secrets (hidden by default as they contain sensitive information)", 146 | ) 147 | parser.add_argument( 148 | "--debug", action="store_true", help="Run in debugging mode (log more)" 149 | ) 150 | # customization options 151 | parser.add_argument( 152 | "--templates-path", help="Path to directory with custom HTML/Jinja2 templates" 153 | ) 154 | parser.add_argument( 155 | "--static-assets-path", 156 | help="Path to custom JS/CSS assets (will be mounted as /assets HTTP path)", 157 | ) 158 | parser.add_argument( 159 | "--object-links", 160 | type=links_dict, 161 | help="Comma-separated list of URL templates per resource type to link to external tools, e.g. 'pods=https://mymonitoringtool/{cluster}/{namespace}/{name}'", 162 | ) 163 | parser.add_argument( 164 | "--label-links", 165 | type=links_dict, 166 | help="Comma-separated list of URL templates per label to link to external tools, e.g. 'application=https://myui/apps/{application}'", 167 | ) 168 | parser.add_argument( 169 | "--sidebar-resource-types", 170 | type=key_value_list_pairs, 171 | help="Comma-separated list of resource types per category, e.g. 'Controllers=deployments,cronjobs;Pod Management=ingresses,pods'", 172 | ) 173 | parser.add_argument( 174 | "--search-default-resource-types", 175 | type=comma_separated_values, 176 | help="Comma-separated list of resource types to use for navbar search by default, e.g. 'deployments,pods'", 177 | ) 178 | parser.add_argument( 179 | "--search-offered-resource-types", 180 | type=comma_separated_values, 181 | help="Comma-separated list of resource types to offer on search page, e.g. 'deployments,pods,nodes'", 182 | ) 183 | parser.add_argument( 184 | "--search-max-concurrency", 185 | type=int, 186 | help="Maximum number of current searches (across clusters/resource types), this allows limiting memory consumption and Kubernetes API calls (default: 100)", 187 | default=100, 188 | ) 189 | parser.add_argument( 190 | "--default-label-columns", 191 | type=key_value_pairs, 192 | help="Comma-separated list of label columns per resource type; multiple entries separated by semicolon, e.g. 'pods=app,version;deployments=team'", 193 | default={}, 194 | ) 195 | parser.add_argument( 196 | "--default-hidden-columns", 197 | type=key_value_pairs, 198 | help="Comma-separated list of columns to hide per resource type; multiple entries separated by semicolon, e.g. 'pods=Nominated Node,Readiness Gates,version;deployments=Selector'", 199 | default={}, 200 | ) 201 | parser.add_argument( 202 | "--default-custom-columns", 203 | type=key_value_pairs2, 204 | help="Semicolon-separated list of Column= pairs per resource type; multiple entries separated by two semicolons, e.g. 'pods=Images=spec.containers[*].image;;deployments=Replicas=spec.replicas'", 205 | default={}, 206 | ) 207 | parser.add_argument( 208 | "--oauth2-authorized-hook", 209 | type=coroutine_function, 210 | help="Optional hook (name of a coroutine like 'mymodule.myfunc') to process OAuth access token response (validate, log, ..)", 211 | ) 212 | parser.add_argument( 213 | "--resource-view-prerender-hook", 214 | type=coroutine_function, 215 | help="Optional hook (name of a coroutine like 'mymodule.myfunc') to process/enrich template context for the resource detail view", 216 | ) 217 | parser.add_argument( 218 | "--preferred-api-versions", 219 | type=key_value_pairs, 220 | help="Preferred Kubernetes apiVersion per resource type, e.g. 'horizontalpodautoscalers=autoscaling/v2beta2;deployments=apps/v1'", 221 | default={}, 222 | ) 223 | parser.add_argument( 224 | "--default-theme", 225 | help="Default CSS theme to use (default: default)", 226 | default="default", 227 | ) 228 | parser.add_argument( 229 | "--theme-options", 230 | type=comma_separated_values, 231 | help="CSS themes the user can choose from (default: all themes)", 232 | default=[], 233 | ) 234 | args = parser.parse_args(argv) 235 | return args 236 | 237 | 238 | def main(argv=None): 239 | args = parse_args(argv) 240 | 241 | logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) 242 | 243 | config_str = ", ".join(f"{k}={v}" for k, v in sorted(vars(args).items())) 244 | logger.info(f"Kubernetes Web View v{__version__} started with {config_str}") 245 | 246 | if args.clusters: 247 | cluster_discoverer = StaticClusterDiscoverer(args.clusters) 248 | elif args.cluster_registry_url: 249 | cluster_discoverer = ClusterRegistryDiscoverer( 250 | args.cluster_registry_url, args.cluster_registry_oauth2_bearer_token_path 251 | ) 252 | elif args.kubeconfig_path: 253 | cluster_discoverer = KubeconfigDiscoverer( 254 | args.kubeconfig_path, args.kubeconfig_contexts 255 | ) 256 | else: 257 | # try to use in-cluster config 258 | try: 259 | cluster_discoverer = ServiceAccountClusterDiscoverer() 260 | except ServiceAccountNotFound: 261 | # fallback to default kubeconfig 262 | cluster_discoverer = KubeconfigDiscoverer( 263 | args.kubeconfig_path, args.kubeconfig_contexts 264 | ) 265 | cluster_manager = ClusterManager( 266 | cluster_discoverer, 267 | args.cluster_label_selector, 268 | args.cluster_auth_token_path, 269 | args.preferred_api_versions, 270 | ) 271 | app = get_app(cluster_manager, args) 272 | aiohttp.web.run_app(app, port=args.port, handle_signals=False) 273 | -------------------------------------------------------------------------------- /kube_web/query_params.py: -------------------------------------------------------------------------------- 1 | SORT = "sort" 2 | SELECTOR = "selector" 3 | FILTER = "filter" 4 | JOIN = "join" 5 | LIMIT = "limit" 6 | HIDDEN_COLUMNS = "hidecols" 7 | LABEL_COLUMNS = "labelcols" 8 | CUSTOM_COLUMNS = "customcols" 9 | DOWNLOAD = "download" 10 | VIEW = "view" 11 | API_VERSION = "api_version" 12 | -------------------------------------------------------------------------------- /kube_web/resource_registry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import List 4 | from typing import Type 5 | 6 | from pykube.objects import APIObject 7 | from pykube.objects import NamespacedAPIObject 8 | 9 | from kube_web import kubernetes 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | throw_exception = object() 14 | 15 | 16 | class ResourceTypeNotFound(Exception): 17 | def __init__(self, resource_type: str, namespaced: bool): 18 | super().__init__( 19 | f"{'Namespaced' if namespaced else 'Cluster'} resource type '{resource_type}' not found" 20 | ) 21 | 22 | 23 | async def discover_api_group(api, group_version, pref_version): 24 | logger.debug(f"Collecting resources for {group_version}..") 25 | response = await kubernetes.api_get(api, version=group_version) 26 | response.raise_for_status() 27 | return group_version, pref_version, response.json()["resources"] 28 | 29 | 30 | async def discover_api_resources(api): 31 | core_version = "v1" 32 | r = await kubernetes.api_get(api, version=core_version) 33 | r.raise_for_status() 34 | for resource in r.json()["resources"]: 35 | # ignore subresources like pods/proxy 36 | if ( 37 | "/" not in resource["name"] 38 | and "get" in resource["verbs"] 39 | and "list" in resource["verbs"] 40 | ): 41 | yield resource["namespaced"], core_version, resource 42 | 43 | r = await kubernetes.api_get(api, version="/apis") 44 | r.raise_for_status() 45 | tasks = [] 46 | for group in r.json()["groups"]: 47 | pref_version = group["preferredVersion"]["groupVersion"] 48 | for version in group["versions"]: 49 | group_version = version["groupVersion"] 50 | tasks.append( 51 | asyncio.create_task( 52 | discover_api_group(api, group_version, pref_version) 53 | ) 54 | ) 55 | 56 | yielded = set() 57 | non_preferred = [] 58 | for result_or_exception in await asyncio.gather(*tasks, return_exceptions=True): 59 | if isinstance(result_or_exception, Exception): 60 | # do not crash if one API group is not available 61 | # see https://codeberg.org/hjacobs/kube-web-view/issues/64 62 | logger.warning(f"Failed to discover API group: {result_or_exception}") 63 | continue 64 | group_version, pref_version, resources = result_or_exception 65 | for resource in resources: 66 | if ( 67 | "/" not in resource["name"] 68 | and "get" in resource["verbs"] 69 | and "list" in resource["verbs"] 70 | ): 71 | if group_version == pref_version: 72 | yield resource["namespaced"], group_version, resource 73 | yielded.add((group_version, resource["name"])) 74 | else: 75 | non_preferred.append( 76 | (resource["namespaced"], group_version, resource) 77 | ) 78 | 79 | for namespaced, group_version, resource in non_preferred: 80 | if (group_version, resource["name"]) not in yielded: 81 | yield namespaced, group_version, resource 82 | 83 | 84 | def cluster_object_factory(kind: str, name: str, api_version: str): 85 | # https://github.com/kelproject/pykube/blob/master/pykube/objects.py#L138 86 | return type( 87 | kind, (APIObject,), {"version": api_version, "endpoint": name, "kind": kind} 88 | ) 89 | 90 | 91 | def namespaced_object_factory(kind: str, name: str, api_version: str): 92 | # https://github.com/kelproject/pykube/blob/master/pykube/objects.py#L138 93 | return type( 94 | kind, 95 | (NamespacedAPIObject,), 96 | {"version": api_version, "endpoint": name, "kind": kind}, 97 | ) 98 | 99 | 100 | async def get_namespaced_resource_types(api): 101 | logger.debug(f"Getting resource types for {api.url}..") 102 | async for namespaced, api_version, resource in discover_api_resources(api): 103 | if namespaced: 104 | clazz = namespaced_object_factory( 105 | resource["kind"], resource["name"], api_version 106 | ) 107 | yield clazz 108 | else: 109 | clazz = cluster_object_factory( 110 | resource["kind"], resource["name"], api_version 111 | ) 112 | yield clazz 113 | 114 | 115 | class ResourceRegistry: 116 | def __init__(self, api, preferred_api_versions: dict): 117 | self.api = api 118 | self.preferred_api_versions = preferred_api_versions 119 | self._lock = asyncio.Lock() 120 | self._cluster_resource_types: List[Type[APIObject]] = [] 121 | self._namespaced_resource_types: List[Type[NamespacedAPIObject]] = [] 122 | 123 | async def initialize(self): 124 | async with self._lock: 125 | if self._namespaced_resource_types and self._cluster_resource_types: 126 | # already initialized! 127 | return 128 | logger.info(f"Initializing resource registry for {self.api.url}..") 129 | namespaced_resource_types = [] 130 | cluster_resource_types = [] 131 | async for clazz in get_namespaced_resource_types(self.api): 132 | if issubclass(clazz, NamespacedAPIObject): 133 | namespaced_resource_types.append(clazz) 134 | else: 135 | cluster_resource_types.append(clazz) 136 | # the first entry is the preferred API version 137 | if clazz.endpoint not in self.preferred_api_versions: 138 | self.preferred_api_versions[clazz.endpoint] = clazz.version 139 | namespaced_resource_types.sort( 140 | key=lambda c: 0 141 | if c.version == self.preferred_api_versions.get(c.endpoint) 142 | else 1 143 | ) 144 | cluster_resource_types.sort( 145 | key=lambda c: 0 146 | if c.version == self.preferred_api_versions.get(c.endpoint) 147 | else 1 148 | ) 149 | self._namespaced_resource_types = namespaced_resource_types 150 | self._cluster_resource_types = cluster_resource_types 151 | 152 | @property 153 | async def cluster_resource_types(self): 154 | if not self._cluster_resource_types: 155 | await self.initialize() 156 | return self._cluster_resource_types 157 | 158 | @property 159 | async def namespaced_resource_types(self): 160 | if not self._namespaced_resource_types: 161 | await self.initialize() 162 | return self._namespaced_resource_types 163 | 164 | async def get_class_by_plural_name( 165 | self, 166 | plural: str, 167 | namespaced: bool, 168 | default=throw_exception, 169 | api_version: str = None, 170 | ): 171 | _types = ( 172 | self.namespaced_resource_types 173 | if namespaced 174 | else self.cluster_resource_types 175 | ) 176 | clazz = None 177 | for c in await _types: 178 | if c.endpoint == plural and (c.version == api_version or not api_version): 179 | clazz = c 180 | break 181 | if not clazz and default is throw_exception: 182 | raise ResourceTypeNotFound(plural, namespaced) 183 | return clazz 184 | 185 | async def get_class_by_api_version_kind( 186 | self, api_version: str, kind: str, namespaced: bool, default=throw_exception 187 | ): 188 | _types = ( 189 | self.namespaced_resource_types 190 | if namespaced 191 | else self.cluster_resource_types 192 | ) 193 | clazz = None 194 | for c in await _types: 195 | if c.version == api_version and c.kind == kind: 196 | clazz = c 197 | break 198 | if not clazz and default is throw_exception: 199 | raise ResourceTypeNotFound(kind, namespaced) 200 | return clazz 201 | -------------------------------------------------------------------------------- /kube_web/selector.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Dict 3 | 4 | 5 | def parse_selector(param: str): 6 | if not param: 7 | return None 8 | selector: Dict[str, Any] = {} 9 | conditions = param.split(",") 10 | for condition in conditions: 11 | key, _, val = condition.partition("=") 12 | key = key.strip() 13 | val = val.strip() 14 | if key.endswith("!"): 15 | if key not in selector: 16 | selector[key] = [] 17 | selector[key].append(val) 18 | else: 19 | selector[key] = val 20 | return selector 21 | 22 | 23 | def selector_matches(selector: dict, labels: dict): 24 | if not selector: 25 | return True 26 | for key, val in selector.items(): 27 | if key.endswith("!"): 28 | if labels.get(key.rstrip("!")) in val: 29 | return False 30 | else: 31 | if labels.get(key) != val: 32 | return False 33 | return True 34 | -------------------------------------------------------------------------------- /kube_web/table.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from functools import partial 3 | 4 | 5 | def _creation_timestamp(row): 6 | return row["object"]["metadata"]["creationTimestamp"] 7 | 8 | 9 | def _column(row, column_index: int): 10 | return (row["cells"][column_index], row["cells"][0]) 11 | 12 | 13 | def sort_table(table, sort_param): 14 | if not sort_param: 15 | return 16 | parts = sort_param.split(":") 17 | sort = parts[0] 18 | reverse = len(parts) > 1 and parts[1] == "desc" 19 | if sort == "Created": 20 | key = _creation_timestamp 21 | elif sort == "Age": 22 | key = _creation_timestamp 23 | reverse = not reverse 24 | else: 25 | column_index = 0 26 | for i, col in enumerate(table.columns): 27 | if col["name"] == sort: 28 | column_index = i 29 | break 30 | key = partial(_column, column_index=column_index) 31 | table.rows.sort(key=key, reverse=reverse) 32 | 33 | 34 | def add_label_columns(table, label_columns_param): 35 | if not label_columns_param: 36 | return 37 | label_columns = list( 38 | filter(None, [l.strip() for l in label_columns_param.split(",")]) 39 | ) 40 | for i, label_column in enumerate(label_columns): 41 | if label_column == "*": 42 | name = "Labels" 43 | else: 44 | name = label_column.capitalize() 45 | table.columns.insert( 46 | i + 1, 47 | { 48 | "name": name, 49 | "description": f"{label_column} label", 50 | "label": label_column, 51 | }, 52 | ) 53 | for row in table.rows: 54 | for i, label in enumerate(label_columns): 55 | if label == "*": 56 | contents = ",".join( 57 | f"{k}={v}" 58 | for k, v in sorted( 59 | row["object"]["metadata"].get("labels", {}).items() 60 | ) 61 | ) 62 | else: 63 | contents = row["object"]["metadata"].get("labels", {}).get(label, "") 64 | row["cells"].insert(i + 1, contents) 65 | 66 | 67 | def filter_table_by_predicate(table, predicate): 68 | for i in range(len(table.rows) - 1, -1, -1): 69 | if not predicate(table.rows[i]): 70 | del table.rows[i] 71 | 72 | 73 | def filter_table(table, filter_param, match_labels=False): 74 | if not filter_param: 75 | return 76 | 77 | key_value = {} 78 | key_value_neq = collections.defaultdict(set) 79 | text_filters = [] 80 | 81 | for part in filter_param.split(","): 82 | k, sep, v = part.partition("=") 83 | if not sep: 84 | text_filters.append(part.strip().lower()) 85 | elif k.endswith("!"): 86 | key_value_neq[k[:-1].strip()].add(v.strip()) 87 | else: 88 | key_value[k.strip()] = v.strip() 89 | 90 | index_filter = {} 91 | index_filter_neq = {} 92 | for i, col in enumerate(table.columns): 93 | filter_value = key_value.get(col["name"]) 94 | if filter_value is not None: 95 | index_filter[i] = filter_value 96 | 97 | filter_values = key_value_neq.get(col["name"]) 98 | if filter_values is not None: 99 | index_filter_neq[i] = filter_values 100 | 101 | if len(key_value) != len(index_filter): 102 | # filter was defined for a column which does not exist 103 | table.rows[:] = [] 104 | return 105 | 106 | if len(key_value_neq) != len(index_filter_neq): 107 | # filter was defined for a column which does not exist 108 | table.rows[:] = [] 109 | return 110 | 111 | for i, row in reversed(list(enumerate(table.rows))): 112 | is_match = False 113 | for j, cell in enumerate(row["cells"]): 114 | filter_value = index_filter.get(j) 115 | is_match = filter_value is None or str(cell) == filter_value 116 | if not is_match: 117 | break 118 | 119 | filter_values = index_filter_neq.get(j) 120 | is_match = not filter_values or str(cell) not in filter_values 121 | if not is_match: 122 | break 123 | 124 | if is_match: 125 | for text in text_filters: 126 | is_match = False 127 | for cell in row["cells"]: 128 | if text in str(cell).lower(): 129 | is_match = True 130 | break 131 | 132 | if not is_match and match_labels: 133 | for label_value in ( 134 | row["object"]["metadata"].get("labels", {}).values() 135 | ): 136 | if text in label_value.lower(): 137 | is_match = True 138 | break 139 | 140 | if not is_match: 141 | del table.rows[i] 142 | 143 | 144 | def merge_cluster_tables(t1, t2): 145 | """Merge two tables with same column from different clusters.""" 146 | column_names1 = list([col["name"] for col in t1.columns]) 147 | column_names2 = list([col["name"] for col in t2.columns]) 148 | if column_names1 == column_names2: 149 | t1.rows.extend(t2.rows) 150 | t1.obj["clusters"].extend(t2.obj["clusters"]) 151 | return t1 152 | else: 153 | added = 0 154 | for column in t2.columns: 155 | if column["name"] not in column_names1: 156 | t1.columns.append(column) 157 | added += 1 158 | column_indicies = {} 159 | for i, column in enumerate(t1.columns): 160 | column_indicies[column["name"]] = i 161 | for row in t1.rows: 162 | for _ in range(added): 163 | row["cells"].append(None) 164 | for row in t2.rows: 165 | new_row_cells = [None] * len(t1.columns) 166 | for name, cell in zip(column_names2, row["cells"]): 167 | idx = column_indicies[name] 168 | new_row_cells[idx] = cell 169 | row["cells"] = new_row_cells 170 | t1.rows.append(row) 171 | 172 | t1.obj["clusters"].extend(t2.obj["clusters"]) 173 | return t1 174 | 175 | 176 | def guess_column_classes(table): 177 | for row in table.rows: 178 | for i, value in enumerate(row["cells"]): 179 | if isinstance(value, int) or isinstance(value, float): 180 | table.columns[i]["class"] = "num" 181 | 182 | break 183 | 184 | 185 | def remove_columns(table, hide_columns_param): 186 | if not hide_columns_param: 187 | return 188 | hide_columns = frozenset( 189 | filter(None, [l.strip() for l in hide_columns_param.split(",")]) 190 | ) 191 | remove_indices = [] 192 | for i, column in enumerate(table.columns): 193 | if column["name"] in hide_columns or "*" in hide_columns: 194 | remove_indices.append(i) 195 | 196 | remove_indices.reverse() 197 | 198 | for i in remove_indices: 199 | del table.columns[i] 200 | 201 | for row in table.rows: 202 | for i in remove_indices: 203 | del row["cells"][i] 204 | -------------------------------------------------------------------------------- /kube_web/templates/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjacobs/kube-web-view/f9816bcf9eaea7ec20697e36b1d8de024662666e/kube_web/templates/assets/favicon.png -------------------------------------------------------------------------------- /kube_web/templates/assets/kube-web.css: -------------------------------------------------------------------------------- 1 | /* */ 2 | html { overflow: scroll; } 3 | 4 | .navbar-end { flex-grow: 0.33; } 5 | .navbar-end .navbar-item { width: 100%; } 6 | .navbar-end form { width: 100%; } 7 | .navbar-end input { color: #fff; background: transparent; box-shadow: none; border: none; border-radius: 0; border-bottom: 1px solid #98b8ed;} 8 | .navbar-end input::placeholder { color: #98b8ed; } 9 | .navbar-end input:focus { border-color: #fff; } 10 | .navbar-end .control .icon { color: #98b8ed; } 11 | .navbar-end .control .icon { color: #98b8ed; } 12 | 13 | .navbar-end a.navbar-item { 14 | width: auto; 15 | } 16 | .navbar-end a.is-active { 17 | background-color: transparent !important; 18 | } 19 | 20 | @keyframes reload { 21 | 0% { background-image: linear-gradient(to top, #2366d1ff 0%, #2366d100 10%); } 22 | 10% { background-image: linear-gradient(to top, #2366d1ff 10%, #2366d100 20%); } 23 | 20% { background-image: linear-gradient(to top, #2366d1ff 20%, #2366d100 30%); } 24 | 30% { background-image: linear-gradient(to top, #2366d1ff 30%, #2366d100 40%); } 25 | 40% { background-image: linear-gradient(to top, #2366d1ff 40%, #2366d100 50%); } 26 | 50% { background-image: linear-gradient(to top, #2366d1ff 50%, #2366d100 60%); } 27 | 60% { background-image: linear-gradient(to top, #2366d1ff 60%, #2366d100 70%); } 28 | 70% { background-image: linear-gradient(to top, #2366d1ff 70%, #2366d100 80%); } 29 | 80% { background-image: linear-gradient(to top, #2366d1ff 80%, #2366d100 90%); } 30 | 90% { background-image: linear-gradient(to top, #2366d1ff 90%, #2366d100 100%); } 31 | 100% { background-image: linear-gradient(to top, #2366d1ff 100%, #2366d1ff 100%); } 32 | } 33 | 34 | .container.is-fluid { margin-left: 0; padding: 1.5rem 0; } 35 | 36 | 37 | /* aside menu has toggle for mobile */ 38 | aside.menu { padding: 1.5rem; margin-top: -1.5rem; } 39 | 40 | main { flex-grow: 1; flex-shrink: 1; } 41 | 42 | h1 .links, 43 | h2 .links { 44 | margin: 0 1rem; 45 | } 46 | h1 { display: flex; } 47 | h1 .meta, 48 | h2 .meta { 49 | margin-left: auto; 50 | font-size: 1rem; 51 | font-weight: normal; 52 | padding-top: 0.5rem; 53 | } 54 | 55 | /* desktop+ */ 56 | @media screen and (min-width: 1024px) { 57 | aside.menu { flex-grow: 0; flex-shrink: 0; flex-basis: 12rem; padding: 1.5rem; margin-right: 1.5rem; margin-top: -1.5rem; } 58 | } 59 | /* mobile & touch */ 60 | @media screen and (max-width: 1023px) { 61 | 62 | /* mobile menu has white background, so change our text color */ 63 | .navbar-end input { color: #363636; } 64 | 65 | aside.menu { padding: 0.5rem 1.5rem; margin-bottom: 1.5rem; } 66 | aside #aside-menu { display: none; padding-top: 1rem; } 67 | 68 | aside a[role=button] { display: block; height: 1rem;} 69 | aside a[role=button] span { display: block; background: hsl(0, 0%, 71%); position: relative; height: 1px; margin: 0 auto; width: 66%; } 70 | aside a[role=button] span:nth-child(1) { top: calc(50% - 1px); } 71 | aside a[role=button] span:nth-child(2) { top: calc(50% + 1px); } 72 | 73 | #aside-menu.is-active { display: block; } 74 | 75 | main { padding: 0 1.5rem; } 76 | 77 | /* show meta created below title */ 78 | h1 { display: block; } 79 | h1 .meta, 80 | h2 .meta { 81 | margin-left: 0; 82 | display: block; 83 | } 84 | } 85 | 86 | table th.num:not([align]), 87 | table td.num:not([align]) { text-align: right; } 88 | 89 | table.table.has-bottom-controls { margin-bottom: 0.5rem; } 90 | .bottom-controls { margin-bottom: 1.5rem; } 91 | 92 | div.section { margin: 0 0 1rem 0; padding: 0; } 93 | div.section h4.title { margin-bottom: 1rem; } 94 | 95 | div.section.collapsible h4.title { cursor: pointer; border-bottom: 1px solid #fff; } 96 | div.section.collapsible h4.title:hover { border-bottom: 1px solid #dbdbdb; } 97 | 98 | div.section.collapsible h4.title:hover::after { content: " ▴"; } 99 | div.section.is-collapsed h4.title { color: #7a7a7a; border-bottom: 1px solid #dbdbdb; } 100 | div.section.is-collapsed h4.title:hover::after { content: " ▾"; } 101 | div.section.is-collapsed .content, 102 | div.section.is-collapsed table { display: none; } 103 | 104 | a.toggle-tools.is-active span { 105 | transform: rotate(180deg); 106 | } 107 | form.tools-form { display: none; margin-bottom: 1rem; } 108 | form.tools-form.is-active { display: block; } 109 | form .checkboxes { } 110 | form .checkboxes label { margin-right: 0.5rem; padding-top: 0.375em; } 111 | 112 | .search-result p { font-size: 0.75rem; } 113 | .search-result h3.title { margin: 0 0 0.5rem 0; } 114 | .search-result { margin-bottom: 1.5rem; } 115 | .search-result p { margin-bottom: 0.25rem; } 116 | .search-result em { font-style: normal; background: hsl(48, 100%, 67%) } 117 | -------------------------------------------------------------------------------- /kube_web/templates/assets/kube-web.js: -------------------------------------------------------------------------------- 1 | // https://bulma.io/documentation/components/navbar/ 2 | document.addEventListener('DOMContentLoaded', () => { 3 | 4 | // Get all "navbar-burger" elements 5 | const $toggleButtons = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger, .aside-burger, .toggle-tools'), 0); 6 | 7 | // Check if there are any navbar burgers 8 | if ($toggleButtons.length > 0) { 9 | 10 | // Add a click event on each of them 11 | $toggleButtons.forEach( el => { 12 | el.addEventListener('click', () => { 13 | 14 | // Get the target from the "data-target" attribute 15 | const target = el.dataset.target; 16 | const $target = document.getElementById(target); 17 | 18 | // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" 19 | el.classList.toggle('is-active'); 20 | $target.classList.toggle('is-active'); 21 | 22 | }); 23 | }); 24 | } 25 | 26 | const $unselectButtons = Array.prototype.slice.call(document.querySelectorAll('.unselect'), 0); 27 | 28 | if ($unselectButtons.length > 0) { 29 | 30 | $unselectButtons.forEach( el => { 31 | el.addEventListener('click', () => { 32 | 33 | // Get the target from the "data-target" attribute 34 | const target = el.dataset.target; 35 | 36 | const $inputs = Array.prototype.slice.call(document.querySelectorAll('#' + target + ' input[type=checkbox]'), 0); 37 | $inputs.forEach( inp => { 38 | inp.checked = false; 39 | }); 40 | 41 | }); 42 | }); 43 | } 44 | 45 | const $forms = Array.prototype.slice.call(document.querySelectorAll('form.tools-form'), 0); 46 | 47 | $forms.forEach( el => { 48 | el.addEventListener('submit', function () { 49 | const $inputs = Array.prototype.slice.call(el.getElementsByTagName('input'), 0); 50 | $inputs.forEach( input => { 51 | // setting the "name" attribute to empty will prevent having an empty query parameter in the URL 52 | if (input.name && !input.value) { 53 | input.name = ''; 54 | } 55 | }); 56 | }); 57 | }); 58 | 59 | const $collapsibleHeaders = Array.prototype.slice.call(document.querySelectorAll('main .collapsible h4.title'), 0); 60 | 61 | $collapsibleHeaders.forEach( el => { 62 | el.addEventListener('click', function () { 63 | const $section = el.parentElement; 64 | $section.classList.toggle('is-collapsed'); 65 | const $collapsed = Array.prototype.slice.call(document.querySelectorAll('main .is-collapsed'), 0); 66 | const names = []; 67 | $collapsed.forEach( el => { 68 | names.push(el.dataset.name); 69 | }); 70 | if (names) { 71 | document.location.hash = "collapsed=" + names.join(","); 72 | } else { 73 | document.location.hash = ""; 74 | } 75 | }); 76 | }); 77 | 78 | const hash = document.location.hash; 79 | if (hash) { 80 | const hashParams = hash.substring(1).split(";"); 81 | hashParams.forEach( param => { 82 | const keyVal = param.split("="); 83 | if (keyVal[0] == "collapsed") { 84 | // collapse all sections mentioned in URL fragment 85 | keyVal[1].split(",").forEach( name => { 86 | const $sections = document.querySelectorAll('main .collapsible[data-name=' + name + ']'); 87 | $sections.forEach( el => { 88 | el.classList.add("is-collapsed"); 89 | }); 90 | }); 91 | } 92 | }); 93 | } 94 | 95 | }); 96 | -------------------------------------------------------------------------------- /kube_web/templates/assets/sortable-theme-minimal.css: -------------------------------------------------------------------------------- 1 | /* line 2, ../sass/_sortable.sass */ 2 | table[data-sortable] { 3 | border-collapse: collapse; 4 | border-spacing: 0; 5 | } 6 | /* line 6, ../sass/_sortable.sass */ 7 | table[data-sortable] th { 8 | vertical-align: bottom; 9 | font-weight: bold; 10 | } 11 | /* line 10, ../sass/_sortable.sass */ 12 | table[data-sortable] th, table[data-sortable] td { 13 | text-align: left; 14 | padding: 10px; 15 | } 16 | /* line 14, ../sass/_sortable.sass */ 17 | table[data-sortable] th:not([data-sortable="false"]) { 18 | -webkit-user-select: none; 19 | -moz-user-select: none; 20 | -ms-user-select: none; 21 | -o-user-select: none; 22 | user-select: none; 23 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 24 | -webkit-touch-callout: none; 25 | cursor: pointer; 26 | } 27 | /* line 26, ../sass/_sortable.sass */ 28 | table[data-sortable] th:after { 29 | content: ""; 30 | visibility: hidden; 31 | display: inline-block; 32 | vertical-align: inherit; 33 | height: 0; 34 | width: 0; 35 | border-width: 5px; 36 | border-style: solid; 37 | border-color: transparent; 38 | margin-right: 1px; 39 | margin-left: 10px; 40 | float: right; 41 | } 42 | /* line 40, ../sass/_sortable.sass */ 43 | table[data-sortable] th[data-sorted="true"]:after { 44 | visibility: visible; 45 | } 46 | /* line 43, ../sass/_sortable.sass */ 47 | table[data-sortable] th[data-sorted-direction="descending"]:after { 48 | border-top-color: inherit; 49 | margin-top: 8px; 50 | } 51 | /* line 47, ../sass/_sortable.sass */ 52 | table[data-sortable] th[data-sorted-direction="ascending"]:after { 53 | border-bottom-color: inherit; 54 | margin-top: 3px; 55 | } 56 | -------------------------------------------------------------------------------- /kube_web/templates/assets/sortable.min.js: -------------------------------------------------------------------------------- 1 | /*! sortable.js 0.8.0 */ 2 | (function(){var a,b,c,d,e,f,g;a="table[data-sortable]",d=/^-?[£$¤]?[\d,.]+%?$/,g=/^\s+|\s+$/g,c=["click"],f="ontouchstart"in document.documentElement,f&&c.push("touchstart"),b=function(a,b,c){return null!=a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)},e={init:function(b){var c,d,f,g,h;for(null==b&&(b={}),null==b.selector&&(b.selector=a),d=document.querySelectorAll(b.selector),h=[],f=0,g=d.length;g>f;f++)c=d[f],h.push(e.initTable(c));return h},initTable:function(a){var b,c,d,f,g,h;if(1===(null!=(h=a.tHead)?h.rows.length:void 0)&&"true"!==a.getAttribute("data-sortable-initialized")){for(a.setAttribute("data-sortable-initialized","true"),d=a.querySelectorAll("th"),b=f=0,g=d.length;g>f;b=++f)c=d[b],"false"!==c.getAttribute("data-sortable")&&e.setupClickableTH(a,c,b);return a}},setupClickableTH:function(a,d,f){var g,h,i,j,k,l;for(i=e.getColumnType(a,f),h=function(b){var c,g,h,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D;if(b.handled===!0)return!1;for(b.handled=!0,m="true"===this.getAttribute("data-sorted"),n=this.getAttribute("data-sorted-direction"),h=m?"ascending"===n?"descending":"ascending":i.defaultSortDirection,p=this.parentNode.querySelectorAll("th"),s=0,w=p.length;w>s;s++)d=p[s],d.setAttribute("data-sorted","false"),d.removeAttribute("data-sorted-direction");if(this.setAttribute("data-sorted","true"),this.setAttribute("data-sorted-direction",h),o=a.tBodies[0],l=[],m){for(D=o.rows,v=0,z=D.length;z>v;v++)g=D[v],l.push(g);for(l.reverse(),B=0,A=l.length;A>B;B++)k=l[B],o.appendChild(k)}else{for(r=null!=i.compare?i.compare:function(a,b){return b-a},c=function(a,b){return a[0]===b[0]?a[2]-b[2]:i.reverse?r(b[0],a[0]):r(a[0],b[0])},C=o.rows,j=t=0,x=C.length;x>t;j=++t)k=C[j],q=e.getNodeValue(k.cells[f]),null!=i.comparator&&(q=i.comparator(q)),l.push([q,k,j]);for(l.sort(c),u=0,y=l.length;y>u;u++)k=l[u],o.appendChild(k[1])}return"function"==typeof window.CustomEvent&&"function"==typeof a.dispatchEvent?a.dispatchEvent(new CustomEvent("Sortable.sorted",{bubbles:!0})):void 0},l=[],j=0,k=c.length;k>j;j++)g=c[j],l.push(b(d,g,h));return l},getColumnType:function(a,b){var c,d,f,g,h,i,j,k,l,m,n;if(d=null!=(l=a.querySelectorAll("th")[b])?l.getAttribute("data-sortable-type"):void 0,null!=d)return e.typesObject[d];for(m=a.tBodies[0].rows,h=0,j=m.length;j>h;h++)for(c=m[h],f=e.getNodeValue(c.cells[b]),n=e.types,i=0,k=n.length;k>i;i++)if(g=n[i],g.match(f))return g;return e.typesObject.alpha},getNodeValue:function(a){var b;return a?(b=a.getAttribute("data-value"),null!==b?b:"undefined"!=typeof a.innerText?a.innerText.replace(g,""):a.textContent.replace(g,"")):""},setupTypes:function(a){var b,c,d,f;for(e.types=a,e.typesObject={},f=[],c=0,d=a.length;d>c;c++)b=a[c],f.push(e.typesObject[b.name]=b);return f}},e.setupTypes([{name:"numeric",defaultSortDirection:"descending",match:function(a){return a.match(d)},comparator:function(a){return parseFloat(a.replace(/[^0-9.-]/g,""),10)||0}},{name:"date",defaultSortDirection:"ascending",reverse:!0,match:function(a){return!isNaN(Date.parse(a))},comparator:function(a){return Date.parse(a)||0}},{name:"alpha",defaultSortDirection:"ascending",match:function(){return!0},compare:function(a,b){return a.localeCompare(b)}}]),setTimeout(e.init,0),"function"==typeof define&&define.amd?define(function(){return e}):"undefined"!=typeof exports?module.exports=e:window.Sortable=e}).call(this); 3 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/darkly/kube-web.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar-end input { color: #fff; border-bottom: 1px solid #98b8ed;} 3 | .navbar-end input::placeholder { color: #98b8ed; } 4 | .navbar-end input:focus { border-color: #fff; } 5 | .navbar-end .control .icon { color: #98b8ed; } 6 | .navbar-end .control .icon { color: #98b8ed; } 7 | 8 | @keyframes reload { 9 | 0% { background-image: linear-gradient(to top, #2f4d6dff 0%, #2f4d6d00 10%); } 10 | 10% { background-image: linear-gradient(to top, #2f4d6dff 10%, #2f4d6d00 20%); } 11 | 20% { background-image: linear-gradient(to top, #2f4d6dff 20%, #2f4d6d00 30%); } 12 | 30% { background-image: linear-gradient(to top, #2f4d6dff 30%, #2f4d6d00 40%); } 13 | 40% { background-image: linear-gradient(to top, #2f4d6dff 40%, #2f4d6d00 50%); } 14 | 50% { background-image: linear-gradient(to top, #2f4d6dff 50%, #2f4d6d00 60%); } 15 | 60% { background-image: linear-gradient(to top, #2f4d6dff 60%, #2f4d6d00 70%); } 16 | 70% { background-image: linear-gradient(to top, #2f4d6dff 70%, #2f4d6d00 80%); } 17 | 80% { background-image: linear-gradient(to top, #2f4d6dff 80%, #2f4d6d00 90%); } 18 | 90% { background-image: linear-gradient(to top, #2f4d6dff 90%, #2f4d6d00 100%); } 19 | 100% { background-image: linear-gradient(to top, #2f4d6dff 100%, #2f4d6dff 100%); } 20 | } 21 | 22 | aside.menu { /* background: #282f2f; */ } 23 | 24 | h1 .meta, 25 | h2 .meta { 26 | color: #5e6d6f; 27 | } 28 | 29 | table th a { color: #f2f2f2; } 30 | table th a:hover { /* color: #3273dc; */ } 31 | 32 | div.section.collapsible h4.title { border-bottom: 1px solid #5e6d6f; } 33 | div.section.collapsible h4.title:hover { background: #282f2f; border-bottom: 1px solid #1abc9c; } 34 | div.section.is-collapsed h4.title { color: #5e6d6f; border-bottom: 1px solid #5e6d6f; } 35 | 36 | /* Pygments CSS 37 | * from pygments.formatters import HtmlFormatter 38 | * print(HtmlFormatter(style='native').get_style_defs('.highlight')) 39 | */ 40 | .highlight .hll { background-color: #404040 } 41 | .highlight { background: #202020; color: #d0d0d0 } 42 | .highlight .c { color: #999999; font-style: italic } /* Comment */ 43 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 44 | .highlight .esc { color: #d0d0d0 } /* Escape */ 45 | .highlight .g { color: #d0d0d0 } /* Generic */ 46 | .highlight .k { color: #6ab825; font-weight: bold } /* Keyword */ 47 | .highlight .l { color: #d0d0d0 } /* Literal */ 48 | .highlight .n { color: #d0d0d0 } /* Name */ 49 | .highlight .o { color: #d0d0d0 } /* Operator */ 50 | .highlight .x { color: #d0d0d0 } /* Other */ 51 | .highlight .p { color: #d0d0d0 } /* Punctuation */ 52 | .highlight .ch { color: #999999; font-style: italic } /* Comment.Hashbang */ 53 | .highlight .cm { color: #999999; font-style: italic } /* Comment.Multiline */ 54 | .highlight .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */ 55 | .highlight .cpf { color: #999999; font-style: italic } /* Comment.PreprocFile */ 56 | .highlight .c1 { color: #999999; font-style: italic } /* Comment.Single */ 57 | .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ 58 | .highlight .gd { color: #d22323 } /* Generic.Deleted */ 59 | .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ 60 | .highlight .gr { color: #d22323 } /* Generic.Error */ 61 | .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ 62 | .highlight .gi { color: #589819 } /* Generic.Inserted */ 63 | .highlight .go { color: #cccccc } /* Generic.Output */ 64 | .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ 65 | .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ 66 | .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ 67 | .highlight .gt { color: #d22323 } /* Generic.Traceback */ 68 | .highlight .kc { color: #6ab825; font-weight: bold } /* Keyword.Constant */ 69 | .highlight .kd { color: #6ab825; font-weight: bold } /* Keyword.Declaration */ 70 | .highlight .kn { color: #6ab825; font-weight: bold } /* Keyword.Namespace */ 71 | .highlight .kp { color: #6ab825 } /* Keyword.Pseudo */ 72 | .highlight .kr { color: #6ab825; font-weight: bold } /* Keyword.Reserved */ 73 | .highlight .kt { color: #6ab825; font-weight: bold } /* Keyword.Type */ 74 | .highlight .ld { color: #d0d0d0 } /* Literal.Date */ 75 | .highlight .m { color: #3677a9 } /* Literal.Number */ 76 | .highlight .s { color: #ed9d13 } /* Literal.String */ 77 | .highlight .na { color: #bbbbbb } /* Name.Attribute */ 78 | .highlight .nb { color: #24909d } /* Name.Builtin */ 79 | .highlight .nc { color: #447fcf; text-decoration: underline } /* Name.Class */ 80 | .highlight .no { color: #40ffff } /* Name.Constant */ 81 | .highlight .nd { color: #ffa500 } /* Name.Decorator */ 82 | .highlight .ni { color: #d0d0d0 } /* Name.Entity */ 83 | .highlight .ne { color: #bbbbbb } /* Name.Exception */ 84 | .highlight .nf { color: #447fcf } /* Name.Function */ 85 | .highlight .nl { color: #d0d0d0 } /* Name.Label */ 86 | .highlight .nn { color: #447fcf; text-decoration: underline } /* Name.Namespace */ 87 | .highlight .nx { color: #d0d0d0 } /* Name.Other */ 88 | .highlight .py { color: #d0d0d0 } /* Name.Property */ 89 | .highlight .nt { color: #6ab825; font-weight: bold } /* Name.Tag */ 90 | .highlight .nv { color: #40ffff } /* Name.Variable */ 91 | .highlight .ow { color: #6ab825; font-weight: bold } /* Operator.Word */ 92 | .highlight .w { color: #666666 } /* Text.Whitespace */ 93 | .highlight .mb { color: #3677a9 } /* Literal.Number.Bin */ 94 | .highlight .mf { color: #3677a9 } /* Literal.Number.Float */ 95 | .highlight .mh { color: #3677a9 } /* Literal.Number.Hex */ 96 | .highlight .mi { color: #3677a9 } /* Literal.Number.Integer */ 97 | .highlight .mo { color: #3677a9 } /* Literal.Number.Oct */ 98 | .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ 99 | .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ 100 | .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ 101 | .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ 102 | .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ 103 | .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ 104 | .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ 105 | .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ 106 | .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ 107 | .highlight .sx { color: #ffa500 } /* Literal.String.Other */ 108 | .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ 109 | .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ 110 | .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ 111 | .highlight .bp { color: #24909d } /* Name.Builtin.Pseudo */ 112 | .highlight .fm { color: #447fcf } /* Name.Function.Magic */ 113 | .highlight .vc { color: #40ffff } /* Name.Variable.Class */ 114 | .highlight .vg { color: #40ffff } /* Name.Variable.Global */ 115 | .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ 116 | .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ 117 | .highlight .il { color: #3677a9 } /* Literal.Number.Integer.Long */ 118 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/darkly/settings.yaml: -------------------------------------------------------------------------------- 1 | navbar_class: primary 2 | button_class: dark 3 | age_color_hue: 0.403 4 | age_color_value: 0.5 5 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/default/kube-web.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar-end input { color: #fff; border-bottom: 1px solid #98b8ed;} 3 | .navbar-end input::placeholder { color: #98b8ed; } 4 | .navbar-end input:focus { border-color: #fff; } 5 | .navbar-end .control .icon { color: #98b8ed; } 6 | .navbar-end .control .icon { color: #98b8ed; } 7 | 8 | @keyframes reload { 9 | 0% { background-image: linear-gradient(to top, #2366d1ff 0%, #2366d100 10%); } 10 | 10% { background-image: linear-gradient(to top, #2366d1ff 10%, #2366d100 20%); } 11 | 20% { background-image: linear-gradient(to top, #2366d1ff 20%, #2366d100 30%); } 12 | 30% { background-image: linear-gradient(to top, #2366d1ff 30%, #2366d100 40%); } 13 | 40% { background-image: linear-gradient(to top, #2366d1ff 40%, #2366d100 50%); } 14 | 50% { background-image: linear-gradient(to top, #2366d1ff 50%, #2366d100 60%); } 15 | 60% { background-image: linear-gradient(to top, #2366d1ff 60%, #2366d100 70%); } 16 | 70% { background-image: linear-gradient(to top, #2366d1ff 70%, #2366d100 80%); } 17 | 80% { background-image: linear-gradient(to top, #2366d1ff 80%, #2366d100 90%); } 18 | 90% { background-image: linear-gradient(to top, #2366d1ff 90%, #2366d100 100%); } 19 | 100% { background-image: linear-gradient(to top, #2366d1ff 100%, #2366d1ff 100%); } 20 | } 21 | 22 | aside.menu { background: #fafafa; } 23 | 24 | h1 .meta, 25 | h2 .meta { 26 | color: #7a7a7a; 27 | } 28 | 29 | table th a { color: #363636; } 30 | table th a:hover { color: #3273dc; } 31 | 32 | div.section.collapsible h4.title { border-bottom: 1px solid #fff; } 33 | div.section.collapsible h4.title:hover { color: #363636; border-bottom: 1px solid #dbdbdb; } 34 | 35 | div.section.is-collapsed h4.title { color: #7a7a7a; border-bottom: 1px solid #dbdbdb; } 36 | 37 | /* Pygments CSS 38 | * from pygments.formatters import HtmlFormatter 39 | * print(HtmlFormatter(style='friendly').get_style_defs('.highlight')) 40 | */ 41 | .highlight .hll { background-color: #ffffcc } 42 | .highlight { background: #f0f0f0; } 43 | .highlight .c { color: #60a0b0; font-style: italic } /* Comment */ 44 | .highlight .err { border: 1px solid #FF0000 } /* Error */ 45 | .highlight .k { color: #007020; font-weight: bold } /* Keyword */ 46 | .highlight .o { color: #666666 } /* Operator */ 47 | .highlight .ch { color: #60a0b0; font-style: italic } /* Comment.Hashbang */ 48 | .highlight .cm { color: #60a0b0; font-style: italic } /* Comment.Multiline */ 49 | .highlight .cp { color: #007020 } /* Comment.Preproc */ 50 | .highlight .cpf { color: #60a0b0; font-style: italic } /* Comment.PreprocFile */ 51 | .highlight .c1 { color: #60a0b0; font-style: italic } /* Comment.Single */ 52 | .highlight .cs { color: #60a0b0; background-color: #fff0f0 } /* Comment.Special */ 53 | .highlight .gd { color: #A00000 } /* Generic.Deleted */ 54 | .highlight .ge { font-style: italic } /* Generic.Emph */ 55 | .highlight .gr { color: #FF0000 } /* Generic.Error */ 56 | .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 57 | .highlight .gi { color: #00A000 } /* Generic.Inserted */ 58 | .highlight .go { color: #888888 } /* Generic.Output */ 59 | .highlight .gp { color: #c65d09; font-weight: bold } /* Generic.Prompt */ 60 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 61 | .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 62 | .highlight .gt { color: #0044DD } /* Generic.Traceback */ 63 | .highlight .kc { color: #007020; font-weight: bold } /* Keyword.Constant */ 64 | .highlight .kd { color: #007020; font-weight: bold } /* Keyword.Declaration */ 65 | .highlight .kn { color: #007020; font-weight: bold } /* Keyword.Namespace */ 66 | .highlight .kp { color: #007020 } /* Keyword.Pseudo */ 67 | .highlight .kr { color: #007020; font-weight: bold } /* Keyword.Reserved */ 68 | .highlight .kt { color: #902000 } /* Keyword.Type */ 69 | .highlight .m { color: #40a070 } /* Literal.Number */ 70 | .highlight .s { color: #4070a0 } /* Literal.String */ 71 | .highlight .na { color: #4070a0 } /* Name.Attribute */ 72 | .highlight .nb { color: #007020 } /* Name.Builtin */ 73 | .highlight .nc { color: #0e84b5; font-weight: bold } /* Name.Class */ 74 | .highlight .no { color: #60add5 } /* Name.Constant */ 75 | .highlight .nd { color: #555555; font-weight: bold } /* Name.Decorator */ 76 | .highlight .ni { color: #d55537; font-weight: bold } /* Name.Entity */ 77 | .highlight .ne { color: #007020 } /* Name.Exception */ 78 | .highlight .nf { color: #06287e } /* Name.Function */ 79 | .highlight .nl { color: #002070; font-weight: bold } /* Name.Label */ 80 | .highlight .nn { color: #0e84b5; font-weight: bold } /* Name.Namespace */ 81 | .highlight .nt { color: #062873; font-weight: bold } /* Name.Tag */ 82 | .highlight .nv { color: #bb60d5 } /* Name.Variable */ 83 | .highlight .ow { color: #007020; font-weight: bold } /* Operator.Word */ 84 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 85 | .highlight .mb { color: #40a070 } /* Literal.Number.Bin */ 86 | .highlight .mf { color: #40a070 } /* Literal.Number.Float */ 87 | .highlight .mh { color: #40a070 } /* Literal.Number.Hex */ 88 | .highlight .mi { color: #40a070 } /* Literal.Number.Integer */ 89 | .highlight .mo { color: #40a070 } /* Literal.Number.Oct */ 90 | .highlight .sa { color: #4070a0 } /* Literal.String.Affix */ 91 | .highlight .sb { color: #4070a0 } /* Literal.String.Backtick */ 92 | .highlight .sc { color: #4070a0 } /* Literal.String.Char */ 93 | .highlight .dl { color: #4070a0 } /* Literal.String.Delimiter */ 94 | .highlight .sd { color: #4070a0; font-style: italic } /* Literal.String.Doc */ 95 | .highlight .s2 { color: #4070a0 } /* Literal.String.Double */ 96 | .highlight .se { color: #4070a0; font-weight: bold } /* Literal.String.Escape */ 97 | .highlight .sh { color: #4070a0 } /* Literal.String.Heredoc */ 98 | .highlight .si { color: #70a0d0; font-style: italic } /* Literal.String.Interpol */ 99 | .highlight .sx { color: #c65d09 } /* Literal.String.Other */ 100 | .highlight .sr { color: #235388 } /* Literal.String.Regex */ 101 | .highlight .s1 { color: #4070a0 } /* Literal.String.Single */ 102 | .highlight .ss { color: #517918 } /* Literal.String.Symbol */ 103 | .highlight .bp { color: #007020 } /* Name.Builtin.Pseudo */ 104 | .highlight .fm { color: #06287e } /* Name.Function.Magic */ 105 | .highlight .vc { color: #bb60d5 } /* Name.Variable.Class */ 106 | .highlight .vg { color: #bb60d5 } /* Name.Variable.Global */ 107 | .highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */ 108 | .highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */ 109 | .highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */ 110 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/default/settings.yaml: -------------------------------------------------------------------------------- 1 | navbar_class: link 2 | button_class: light 3 | age_color_hue: 0.39 4 | age_color_value: 0.21 5 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/flatly/kube-web.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar-end input { color: #fff; border-bottom: 1px solid rgba(255, 255, 255, 0.6);} 3 | .navbar-end input::placeholder { color: rgba(255, 255, 255, 0.6); } 4 | .navbar-end input:focus { border-color: #fff; } 5 | .navbar-end .control .icon { color: rgba(255, 255, 255, 0.6); } 6 | 7 | @keyframes reload { 8 | 0% { background-image: linear-gradient(to top, #2b3c4eff 0%, #2b3c4e00 10%); } 9 | 10% { background-image: linear-gradient(to top, #2b3c4eff 10%, #2b3c4e00 20%); } 10 | 20% { background-image: linear-gradient(to top, #2b3c4eff 20%, #2b3c4e00 30%); } 11 | 30% { background-image: linear-gradient(to top, #2b3c4eff 30%, #2b3c4e00 40%); } 12 | 40% { background-image: linear-gradient(to top, #2b3c4eff 40%, #2b3c4e00 50%); } 13 | 50% { background-image: linear-gradient(to top, #2b3c4eff 50%, #2b3c4e00 60%); } 14 | 60% { background-image: linear-gradient(to top, #2b3c4eff 60%, #2b3c4e00 70%); } 15 | 70% { background-image: linear-gradient(to top, #2b3c4eff 70%, #2b3c4e00 80%); } 16 | 80% { background-image: linear-gradient(to top, #2b3c4eff 80%, #2b3c4e00 90%); } 17 | 90% { background-image: linear-gradient(to top, #2b3c4eff 90%, #2b3c4e00 100%); } 18 | 100% { background-image: linear-gradient(to top, #2b3c4eff 100%, #2b3c4eff 100%); } 19 | } 20 | 21 | aside.menu { background: #fafafa; } 22 | 23 | h1 .meta, 24 | h2 .meta { 25 | color: #8c9b9d; 26 | } 27 | 28 | table th a { color: #363636; } 29 | table th a:hover { color: #148f77; } 30 | 31 | div.section.collapsible h4.title { border-bottom: 1px solid #fff; } 32 | div.section.collapsible h4.title:hover { color: #148f77; border-bottom: 1px solid #148f77; } 33 | 34 | div.section.is-collapsed h4.title { color: #8c9b9d; border-bottom: 1px solid #dee2e5; } 35 | 36 | /* Pygments CSS 37 | * from pygments.formatters import HtmlFormatter 38 | * print(HtmlFormatter(style='friendly').get_style_defs('.highlight')) 39 | */ 40 | .highlight .hll { background-color: #ffffcc } 41 | .highlight { background: #f0f0f0; } 42 | .highlight .c { color: #60a0b0; font-style: italic } /* Comment */ 43 | .highlight .err { border: 1px solid #FF0000 } /* Error */ 44 | .highlight .k { color: #007020; font-weight: bold } /* Keyword */ 45 | .highlight .o { color: #666666 } /* Operator */ 46 | .highlight .ch { color: #60a0b0; font-style: italic } /* Comment.Hashbang */ 47 | .highlight .cm { color: #60a0b0; font-style: italic } /* Comment.Multiline */ 48 | .highlight .cp { color: #007020 } /* Comment.Preproc */ 49 | .highlight .cpf { color: #60a0b0; font-style: italic } /* Comment.PreprocFile */ 50 | .highlight .c1 { color: #60a0b0; font-style: italic } /* Comment.Single */ 51 | .highlight .cs { color: #60a0b0; background-color: #fff0f0 } /* Comment.Special */ 52 | .highlight .gd { color: #A00000 } /* Generic.Deleted */ 53 | .highlight .ge { font-style: italic } /* Generic.Emph */ 54 | .highlight .gr { color: #FF0000 } /* Generic.Error */ 55 | .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 56 | .highlight .gi { color: #00A000 } /* Generic.Inserted */ 57 | .highlight .go { color: #888888 } /* Generic.Output */ 58 | .highlight .gp { color: #c65d09; font-weight: bold } /* Generic.Prompt */ 59 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 60 | .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 61 | .highlight .gt { color: #0044DD } /* Generic.Traceback */ 62 | .highlight .kc { color: #007020; font-weight: bold } /* Keyword.Constant */ 63 | .highlight .kd { color: #007020; font-weight: bold } /* Keyword.Declaration */ 64 | .highlight .kn { color: #007020; font-weight: bold } /* Keyword.Namespace */ 65 | .highlight .kp { color: #007020 } /* Keyword.Pseudo */ 66 | .highlight .kr { color: #007020; font-weight: bold } /* Keyword.Reserved */ 67 | .highlight .kt { color: #902000 } /* Keyword.Type */ 68 | .highlight .m { color: #40a070 } /* Literal.Number */ 69 | .highlight .s { color: #4070a0 } /* Literal.String */ 70 | .highlight .na { color: #4070a0 } /* Name.Attribute */ 71 | .highlight .nb { color: #007020 } /* Name.Builtin */ 72 | .highlight .nc { color: #0e84b5; font-weight: bold } /* Name.Class */ 73 | .highlight .no { color: #60add5 } /* Name.Constant */ 74 | .highlight .nd { color: #555555; font-weight: bold } /* Name.Decorator */ 75 | .highlight .ni { color: #d55537; font-weight: bold } /* Name.Entity */ 76 | .highlight .ne { color: #007020 } /* Name.Exception */ 77 | .highlight .nf { color: #06287e } /* Name.Function */ 78 | .highlight .nl { color: #002070; font-weight: bold } /* Name.Label */ 79 | .highlight .nn { color: #0e84b5; font-weight: bold } /* Name.Namespace */ 80 | .highlight .nt { color: #062873; font-weight: bold } /* Name.Tag */ 81 | .highlight .nv { color: #bb60d5 } /* Name.Variable */ 82 | .highlight .ow { color: #007020; font-weight: bold } /* Operator.Word */ 83 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 84 | .highlight .mb { color: #40a070 } /* Literal.Number.Bin */ 85 | .highlight .mf { color: #40a070 } /* Literal.Number.Float */ 86 | .highlight .mh { color: #40a070 } /* Literal.Number.Hex */ 87 | .highlight .mi { color: #40a070 } /* Literal.Number.Integer */ 88 | .highlight .mo { color: #40a070 } /* Literal.Number.Oct */ 89 | .highlight .sa { color: #4070a0 } /* Literal.String.Affix */ 90 | .highlight .sb { color: #4070a0 } /* Literal.String.Backtick */ 91 | .highlight .sc { color: #4070a0 } /* Literal.String.Char */ 92 | .highlight .dl { color: #4070a0 } /* Literal.String.Delimiter */ 93 | .highlight .sd { color: #4070a0; font-style: italic } /* Literal.String.Doc */ 94 | .highlight .s2 { color: #4070a0 } /* Literal.String.Double */ 95 | .highlight .se { color: #4070a0; font-weight: bold } /* Literal.String.Escape */ 96 | .highlight .sh { color: #4070a0 } /* Literal.String.Heredoc */ 97 | .highlight .si { color: #70a0d0; font-style: italic } /* Literal.String.Interpol */ 98 | .highlight .sx { color: #c65d09 } /* Literal.String.Other */ 99 | .highlight .sr { color: #235388 } /* Literal.String.Regex */ 100 | .highlight .s1 { color: #4070a0 } /* Literal.String.Single */ 101 | .highlight .ss { color: #517918 } /* Literal.String.Symbol */ 102 | .highlight .bp { color: #007020 } /* Name.Builtin.Pseudo */ 103 | .highlight .fm { color: #06287e } /* Name.Function.Magic */ 104 | .highlight .vc { color: #bb60d5 } /* Name.Variable.Class */ 105 | .highlight .vg { color: #bb60d5 } /* Name.Variable.Global */ 106 | .highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */ 107 | .highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */ 108 | .highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */ 109 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/flatly/settings.yaml: -------------------------------------------------------------------------------- 1 | navbar_class: primary 2 | button_class: light 3 | age_color_hue: 0.403 4 | age_color_value: 0.21 5 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/slate/kube-web.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar-end input { color: #fff; border-bottom: 1px solid rgba(255, 255, 255, .8);} 3 | .navbar-end input::placeholder { color: rgba(255, 255, 255, .8); } 4 | .navbar-end input:focus { border-color: #fff; } 5 | .navbar-end .control .icon { color: rgba(255, 255, 255, 0.8); } 6 | 7 | @keyframes reload { 8 | 0% { background-image: linear-gradient(to top, #464a4fff 0%, #464a4f00 10%); } 9 | 10% { background-image: linear-gradient(to top, #464a4fff 10%, #464a4f00 20%); } 10 | 20% { background-image: linear-gradient(to top, #464a4fff 20%, #464a4f00 30%); } 11 | 30% { background-image: linear-gradient(to top, #464a4fff 30%, #464a4f00 40%); } 12 | 40% { background-image: linear-gradient(to top, #464a4fff 40%, #464a4f00 50%); } 13 | 50% { background-image: linear-gradient(to top, #464a4fff 50%, #464a4f00 60%); } 14 | 60% { background-image: linear-gradient(to top, #464a4fff 60%, #464a4f00 70%); } 15 | 70% { background-image: linear-gradient(to top, #464a4fff 70%, #464a4f00 80%); } 16 | 80% { background-image: linear-gradient(to top, #464a4fff 80%, #464a4f00 90%); } 17 | 90% { background-image: linear-gradient(to top, #464a4fff 90%, #464a4f00 100%); } 18 | 100% { background-image: linear-gradient(to top, #464a4fff 100%, #464a4fff 100%); } 19 | } 20 | 21 | aside.menu { /* background: #282f2f; */ } 22 | 23 | h1 .meta, 24 | h2 .meta { 25 | color: #52575c; 26 | } 27 | 28 | table th a { } 29 | table th a:hover { } 30 | 31 | div.section.collapsible h4.title { border-bottom: 1px solid #52575c; } 32 | div.section.collapsible h4.title:hover { background: #3a3f44; border-bottom: 1px solid #fff; } 33 | div.section.is-collapsed h4.title { color: #52575c; border-bottom: 1px solid #52575c; } 34 | 35 | /* Pygments CSS 36 | * from pygments.formatters import HtmlFormatter 37 | * print(HtmlFormatter(style='native').get_style_defs('.highlight')) 38 | */ 39 | .highlight .hll { background-color: #404040 } 40 | .highlight { background: #202020; color: #d0d0d0 } 41 | .highlight .c { color: #999999; font-style: italic } /* Comment */ 42 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 43 | .highlight .esc { color: #d0d0d0 } /* Escape */ 44 | .highlight .g { color: #d0d0d0 } /* Generic */ 45 | .highlight .k { color: #6ab825; font-weight: bold } /* Keyword */ 46 | .highlight .l { color: #d0d0d0 } /* Literal */ 47 | .highlight .n { color: #d0d0d0 } /* Name */ 48 | .highlight .o { color: #d0d0d0 } /* Operator */ 49 | .highlight .x { color: #d0d0d0 } /* Other */ 50 | .highlight .p { color: #d0d0d0 } /* Punctuation */ 51 | .highlight .ch { color: #999999; font-style: italic } /* Comment.Hashbang */ 52 | .highlight .cm { color: #999999; font-style: italic } /* Comment.Multiline */ 53 | .highlight .cp { color: #cd2828; font-weight: bold } /* Comment.Preproc */ 54 | .highlight .cpf { color: #999999; font-style: italic } /* Comment.PreprocFile */ 55 | .highlight .c1 { color: #999999; font-style: italic } /* Comment.Single */ 56 | .highlight .cs { color: #e50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ 57 | .highlight .gd { color: #d22323 } /* Generic.Deleted */ 58 | .highlight .ge { color: #d0d0d0; font-style: italic } /* Generic.Emph */ 59 | .highlight .gr { color: #d22323 } /* Generic.Error */ 60 | .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ 61 | .highlight .gi { color: #589819 } /* Generic.Inserted */ 62 | .highlight .go { color: #cccccc } /* Generic.Output */ 63 | .highlight .gp { color: #aaaaaa } /* Generic.Prompt */ 64 | .highlight .gs { color: #d0d0d0; font-weight: bold } /* Generic.Strong */ 65 | .highlight .gu { color: #ffffff; text-decoration: underline } /* Generic.Subheading */ 66 | .highlight .gt { color: #d22323 } /* Generic.Traceback */ 67 | .highlight .kc { color: #6ab825; font-weight: bold } /* Keyword.Constant */ 68 | .highlight .kd { color: #6ab825; font-weight: bold } /* Keyword.Declaration */ 69 | .highlight .kn { color: #6ab825; font-weight: bold } /* Keyword.Namespace */ 70 | .highlight .kp { color: #6ab825 } /* Keyword.Pseudo */ 71 | .highlight .kr { color: #6ab825; font-weight: bold } /* Keyword.Reserved */ 72 | .highlight .kt { color: #6ab825; font-weight: bold } /* Keyword.Type */ 73 | .highlight .ld { color: #d0d0d0 } /* Literal.Date */ 74 | .highlight .m { color: #3677a9 } /* Literal.Number */ 75 | .highlight .s { color: #ed9d13 } /* Literal.String */ 76 | .highlight .na { color: #bbbbbb } /* Name.Attribute */ 77 | .highlight .nb { color: #24909d } /* Name.Builtin */ 78 | .highlight .nc { color: #447fcf; text-decoration: underline } /* Name.Class */ 79 | .highlight .no { color: #40ffff } /* Name.Constant */ 80 | .highlight .nd { color: #ffa500 } /* Name.Decorator */ 81 | .highlight .ni { color: #d0d0d0 } /* Name.Entity */ 82 | .highlight .ne { color: #bbbbbb } /* Name.Exception */ 83 | .highlight .nf { color: #447fcf } /* Name.Function */ 84 | .highlight .nl { color: #d0d0d0 } /* Name.Label */ 85 | .highlight .nn { color: #447fcf; text-decoration: underline } /* Name.Namespace */ 86 | .highlight .nx { color: #d0d0d0 } /* Name.Other */ 87 | .highlight .py { color: #d0d0d0 } /* Name.Property */ 88 | .highlight .nt { color: #6ab825; font-weight: bold } /* Name.Tag */ 89 | .highlight .nv { color: #40ffff } /* Name.Variable */ 90 | .highlight .ow { color: #6ab825; font-weight: bold } /* Operator.Word */ 91 | .highlight .w { color: #666666 } /* Text.Whitespace */ 92 | .highlight .mb { color: #3677a9 } /* Literal.Number.Bin */ 93 | .highlight .mf { color: #3677a9 } /* Literal.Number.Float */ 94 | .highlight .mh { color: #3677a9 } /* Literal.Number.Hex */ 95 | .highlight .mi { color: #3677a9 } /* Literal.Number.Integer */ 96 | .highlight .mo { color: #3677a9 } /* Literal.Number.Oct */ 97 | .highlight .sa { color: #ed9d13 } /* Literal.String.Affix */ 98 | .highlight .sb { color: #ed9d13 } /* Literal.String.Backtick */ 99 | .highlight .sc { color: #ed9d13 } /* Literal.String.Char */ 100 | .highlight .dl { color: #ed9d13 } /* Literal.String.Delimiter */ 101 | .highlight .sd { color: #ed9d13 } /* Literal.String.Doc */ 102 | .highlight .s2 { color: #ed9d13 } /* Literal.String.Double */ 103 | .highlight .se { color: #ed9d13 } /* Literal.String.Escape */ 104 | .highlight .sh { color: #ed9d13 } /* Literal.String.Heredoc */ 105 | .highlight .si { color: #ed9d13 } /* Literal.String.Interpol */ 106 | .highlight .sx { color: #ffa500 } /* Literal.String.Other */ 107 | .highlight .sr { color: #ed9d13 } /* Literal.String.Regex */ 108 | .highlight .s1 { color: #ed9d13 } /* Literal.String.Single */ 109 | .highlight .ss { color: #ed9d13 } /* Literal.String.Symbol */ 110 | .highlight .bp { color: #24909d } /* Name.Builtin.Pseudo */ 111 | .highlight .fm { color: #447fcf } /* Name.Function.Magic */ 112 | .highlight .vc { color: #40ffff } /* Name.Variable.Class */ 113 | .highlight .vg { color: #40ffff } /* Name.Variable.Global */ 114 | .highlight .vi { color: #40ffff } /* Name.Variable.Instance */ 115 | .highlight .vm { color: #40ffff } /* Name.Variable.Magic */ 116 | .highlight .il { color: #3677a9 } /* Literal.Number.Integer.Long */ 117 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/slate/settings.yaml: -------------------------------------------------------------------------------- 1 | navbar_class: primary 2 | button_class: light 3 | age_color_hue: 0.33 4 | age_color_value: 0.5 5 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/superhero/kube-web.css: -------------------------------------------------------------------------------- 1 | 2 | .navbar-end input { color: #fff; border-bottom: 1px solid #98b8ed;} 3 | .navbar-end input::placeholder { color: #98b8ed; } 4 | .navbar-end input:focus { border-color: #fff; } 5 | .navbar-end .control .icon { color: #98b8ed; } 6 | .navbar-end .control .icon { color: #98b8ed; } 7 | 8 | @keyframes reload { 9 | 0% { background-image: linear-gradient(to top, #c85e17ff 0%, #c85e1700 10%); } 10 | 10% { background-image: linear-gradient(to top, #c85e17ff 10%, #c85e1700 20%); } 11 | 20% { background-image: linear-gradient(to top, #c85e17ff 20%, #c85e1700 30%); } 12 | 30% { background-image: linear-gradient(to top, #c85e17ff 30%, #c85e1700 40%); } 13 | 40% { background-image: linear-gradient(to top, #c85e17ff 40%, #c85e1700 50%); } 14 | 50% { background-image: linear-gradient(to top, #c85e17ff 50%, #c85e1700 60%); } 15 | 60% { background-image: linear-gradient(to top, #c85e17ff 60%, #c85e1700 70%); } 16 | 70% { background-image: linear-gradient(to top, #c85e17ff 70%, #c85e1700 80%); } 17 | 80% { background-image: linear-gradient(to top, #c85e17ff 80%, #c85e1700 90%); } 18 | 90% { background-image: linear-gradient(to top, #c85e17ff 90%, #c85e1700 100%); } 19 | 100% { background-image: linear-gradient(to top, #c85e17ff 100%, #c85e17ff 100%); } 20 | } 21 | 22 | aside.menu { background: transparent; } 23 | 24 | h1 .meta, 25 | h2 .meta { 26 | color: #4e5d6c; 27 | } 28 | 29 | table th a { color: #f2f2f2; } 30 | table th a:hover { /* color: #3273dc; */ } 31 | 32 | div.section.collapsible h4.title { border-bottom: 1px solid #4e5d6c; } 33 | div.section.collapsible h4.title:hover { color: #bdcbdb; border-bottom: 1px solid #bdcbdb; } 34 | div.section.is-collapsed h4.title { color: #8694a4; border-bottom: 1px solid #4e5d6c; } 35 | 36 | .highlight .hll { background-color: #333333 } 37 | .highlight { background: #111111; color: #ffffff } 38 | .highlight .c { color: #008800; font-style: italic; background-color: #0f140f } /* Comment */ 39 | .highlight .err { color: #ffffff } /* Error */ 40 | .highlight .esc { color: #ffffff } /* Escape */ 41 | .highlight .g { color: #ffffff } /* Generic */ 42 | .highlight .k { color: #fb660a; font-weight: bold } /* Keyword */ 43 | .highlight .l { color: #ffffff } /* Literal */ 44 | .highlight .n { color: #ffffff } /* Name */ 45 | .highlight .o { color: #ffffff } /* Operator */ 46 | .highlight .x { color: #ffffff } /* Other */ 47 | .highlight .p { color: #ffffff } /* Punctuation */ 48 | .highlight .ch { color: #008800; font-style: italic; background-color: #0f140f } /* Comment.Hashbang */ 49 | .highlight .cm { color: #008800; font-style: italic; background-color: #0f140f } /* Comment.Multiline */ 50 | .highlight .cp { color: #ff0007; font-weight: bold; font-style: italic; background-color: #0f140f } /* Comment.Preproc */ 51 | .highlight .cpf { color: #008800; font-style: italic; background-color: #0f140f } /* Comment.PreprocFile */ 52 | .highlight .c1 { color: #008800; font-style: italic; background-color: #0f140f } /* Comment.Single */ 53 | .highlight .cs { color: #008800; font-style: italic; background-color: #0f140f } /* Comment.Special */ 54 | .highlight .gd { color: #ffffff } /* Generic.Deleted */ 55 | .highlight .ge { color: #ffffff } /* Generic.Emph */ 56 | .highlight .gr { color: #ffffff } /* Generic.Error */ 57 | .highlight .gh { color: #ffffff; font-weight: bold } /* Generic.Heading */ 58 | .highlight .gi { color: #ffffff } /* Generic.Inserted */ 59 | .highlight .go { color: #444444; background-color: #222222 } /* Generic.Output */ 60 | .highlight .gp { color: #ffffff } /* Generic.Prompt */ 61 | .highlight .gs { color: #ffffff } /* Generic.Strong */ 62 | .highlight .gu { color: #ffffff; font-weight: bold } /* Generic.Subheading */ 63 | .highlight .gt { color: #ffffff } /* Generic.Traceback */ 64 | .highlight .kc { color: #fb660a; font-weight: bold } /* Keyword.Constant */ 65 | .highlight .kd { color: #fb660a; font-weight: bold } /* Keyword.Declaration */ 66 | .highlight .kn { color: #fb660a; font-weight: bold } /* Keyword.Namespace */ 67 | .highlight .kp { color: #fb660a } /* Keyword.Pseudo */ 68 | .highlight .kr { color: #fb660a; font-weight: bold } /* Keyword.Reserved */ 69 | .highlight .kt { color: #cdcaa9; font-weight: bold } /* Keyword.Type */ 70 | .highlight .ld { color: #ffffff } /* Literal.Date */ 71 | .highlight .m { color: #0086f7; font-weight: bold } /* Literal.Number */ 72 | .highlight .s { color: #0086d2 } /* Literal.String */ 73 | .highlight .na { color: #ff0086; font-weight: bold } /* Name.Attribute */ 74 | .highlight .nb { color: #ffffff } /* Name.Builtin */ 75 | .highlight .nc { color: #ffffff } /* Name.Class */ 76 | .highlight .no { color: #0086d2 } /* Name.Constant */ 77 | .highlight .nd { color: #ffffff } /* Name.Decorator */ 78 | .highlight .ni { color: #ffffff } /* Name.Entity */ 79 | .highlight .ne { color: #ffffff } /* Name.Exception */ 80 | .highlight .nf { color: #ff0086; font-weight: bold } /* Name.Function */ 81 | .highlight .nl { color: #ffffff } /* Name.Label */ 82 | .highlight .nn { color: #ffffff } /* Name.Namespace */ 83 | .highlight .nx { color: #ffffff } /* Name.Other */ 84 | .highlight .py { color: #ffffff } /* Name.Property */ 85 | .highlight .nt { color: #fb660a; font-weight: bold } /* Name.Tag */ 86 | .highlight .nv { color: #fb660a } /* Name.Variable */ 87 | .highlight .ow { color: #ffffff } /* Operator.Word */ 88 | .highlight .w { color: #888888 } /* Text.Whitespace */ 89 | .highlight .mb { color: #0086f7; font-weight: bold } /* Literal.Number.Bin */ 90 | .highlight .mf { color: #0086f7; font-weight: bold } /* Literal.Number.Float */ 91 | .highlight .mh { color: #0086f7; font-weight: bold } /* Literal.Number.Hex */ 92 | .highlight .mi { color: #0086f7; font-weight: bold } /* Literal.Number.Integer */ 93 | .highlight .mo { color: #0086f7; font-weight: bold } /* Literal.Number.Oct */ 94 | .highlight .sa { color: #0086d2 } /* Literal.String.Affix */ 95 | .highlight .sb { color: #0086d2 } /* Literal.String.Backtick */ 96 | .highlight .sc { color: #0086d2 } /* Literal.String.Char */ 97 | .highlight .dl { color: #0086d2 } /* Literal.String.Delimiter */ 98 | .highlight .sd { color: #0086d2 } /* Literal.String.Doc */ 99 | .highlight .s2 { color: #0086d2 } /* Literal.String.Double */ 100 | .highlight .se { color: #0086d2 } /* Literal.String.Escape */ 101 | .highlight .sh { color: #0086d2 } /* Literal.String.Heredoc */ 102 | .highlight .si { color: #0086d2 } /* Literal.String.Interpol */ 103 | .highlight .sx { color: #0086d2 } /* Literal.String.Other */ 104 | .highlight .sr { color: #0086d2 } /* Literal.String.Regex */ 105 | .highlight .s1 { color: #0086d2 } /* Literal.String.Single */ 106 | .highlight .ss { color: #0086d2 } /* Literal.String.Symbol */ 107 | .highlight .bp { color: #ffffff } /* Name.Builtin.Pseudo */ 108 | .highlight .fm { color: #ff0086; font-weight: bold } /* Name.Function.Magic */ 109 | .highlight .vc { color: #fb660a } /* Name.Variable.Class */ 110 | .highlight .vg { color: #fb660a } /* Name.Variable.Global */ 111 | .highlight .vi { color: #fb660a } /* Name.Variable.Instance */ 112 | .highlight .vm { color: #fb660a } /* Name.Variable.Magic */ 113 | .highlight .il { color: #0086f7; font-weight: bold } /* Literal.Number.Integer.Long */ 114 | -------------------------------------------------------------------------------- /kube_web/templates/assets/themes/superhero/settings.yaml: -------------------------------------------------------------------------------- 1 | navbar_class: primary 2 | button_class: light 3 | age_color_hue: 0.33 4 | age_color_value: 0.8 5 | -------------------------------------------------------------------------------- /kube_web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} - Kubernetes Web View 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% include "partials/extrahead.html" %} 16 | 17 | 18 | {% include "partials/navbar.html" %} 19 |
20 | {% include "partials/sidebar.html" %} 21 |
22 | {% block content %}{% endblock %} 23 |
24 |
25 | {% include "partials/footer.html" %} 26 | {% if reload: %} 27 | 37 | {% endif %} 38 | 39 | 40 | -------------------------------------------------------------------------------- /kube_web/templates/cluster.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ cluster }} Cluster{% endblock %} 3 | {% block content %} 4 | 9 | 10 |

{{ cluster }}

11 | 12 |
13 | {% for key, val in cluster_obj.labels.items()|sort: %} 14 | {{ key }}: {{ val }} 15 | {% endfor %} 16 |
17 | 18 |
19 | API URL: {{ cluster_obj.api.url }} 20 |
21 | 22 | 34 | 35 |
36 |

Namespaces

37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% for resource in namespaces: %} 47 | 48 | 49 | 50 | 55 | 56 | 57 | {% endfor %} 58 |
NameLabelsCreated
{{ resource.name }} 51 | {% for key, val in resource.labels.items()|sort: %} 52 | {{ key }}: {{ val }} 53 | {% endfor %} 54 | {{ resource.metadata.creationTimestamp.replace('T', ' ').replace('Z', '') }}
59 |
60 | 65 |
66 |
67 |
68 | 69 |
70 |

Cluster Resource Types

71 | 72 | 73 | 74 | 75 | 76 | 77 | {% for resource_type in resource_types: %} 78 | 79 | 80 | 81 | 82 | 83 | {% endfor %} 84 |
KindPluralVersion
{{ resource_type.kind }}{{ resource_type.endpoint }}{{ resource_type.version }}
85 |
86 | {% endblock %} 87 | -------------------------------------------------------------------------------- /kube_web/templates/clusters.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Clusters{% endblock %} 3 | {% block content %} 4 |

Clusters

5 |
6 | 7 | 8 |
9 |
10 |
11 |

12 | 13 | 14 | 15 | 16 |

17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 | 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {% for cluster in clusters: %} 45 | 46 | 47 | 48 | 49 | 54 | 55 | {% else: %} 56 | 57 | 58 | 59 | {% endfor %} 60 |
NameAPI URLLabels
{{ cluster.name }}{{ cluster.api.url }} 50 | {% for key, val in cluster.labels.items()|sort: %} 51 | {{ key }}: {{ val }} 52 | {% endfor %} 53 |
No clusters found.
61 |
62 | 67 |
68 |
69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /kube_web/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ error_title }}{% endblock %} 3 | {% block content %} 4 |
5 |
6 |

{{ error_title }}

7 |
8 |
{{ error_text }}
9 |
10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /kube_web/templates/partials/events.html: -------------------------------------------------------------------------------- 1 |

Events

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% for event in events: %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% else: %} 19 | 20 | 21 | 22 | {% endfor %} 23 |
TypeReasonAgeFromMessage
{{ event.obj.type }}{{ event.obj.reason }}{{ event.obj.lastTimestamp.replace('T', ' ').replace('Z', '') if event.obj.lastTimestamp else "Unknown" }}{{ event.obj.source.component }}{{ event.obj.message }}
No events found.
24 | -------------------------------------------------------------------------------- /kube_web/templates/partials/extrahead.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjacobs/kube-web-view/f9816bcf9eaea7ec20697e36b1d8de024662666e/kube_web/templates/partials/extrahead.html -------------------------------------------------------------------------------- /kube_web/templates/partials/footer.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /kube_web/templates/partials/navbar.html: -------------------------------------------------------------------------------- 1 | 64 | -------------------------------------------------------------------------------- /kube_web/templates/partials/sidebar.html: -------------------------------------------------------------------------------- 1 | 39 | -------------------------------------------------------------------------------- /kube_web/templates/partials/yaml.html: -------------------------------------------------------------------------------- 1 |
2 | {{ resource.obj|yaml|highlight(linenos=True)|safe }} 3 |
4 | 26 | -------------------------------------------------------------------------------- /kube_web/templates/preferences.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Preferences{% endblock %} 3 | {% block content %} 4 |

Preferences

5 |
6 | 7 |
8 | 9 |

10 | 11 | 16 | 17 | 18 | 19 | 20 |

21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /kube_web/templates/resource-list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ plural }} in {{ cluster }}{% endblock %} 3 | {% block content %} 4 | 19 | 20 | {% for table in tables: %} 21 |

{{ table.api_obj_class.kind|pluralize }} 22 | 23 | 31 | 32 | 33 | 36 | 37 | 38 | 41 | 42 | 43 | 44 |

45 | 46 |
47 | {% if rel_url.query.join: %} 48 | 49 | {% endif %} 50 | {% if rel_url.query.sort: %} 51 | 52 | {% endif %} 53 | {% if rel_url.query.customcols: %} 54 | 55 | {% endif %} 56 | {% if rel_url.query.hidecols: %} 57 | 58 | {% endif %} 59 |
60 |
61 | 62 |
63 |
64 |
65 |

66 | 67 | 68 | 69 | 70 |

71 |
72 | 73 |
74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |

83 | 84 | 85 | 86 | 87 |

88 |
89 |
90 |
91 | 92 |
93 |
94 | 95 |
96 |
97 |
98 |

99 | 100 | 101 | 102 | 103 |

104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | 113 |
114 |
115 |
116 |
117 | 118 | {% if table.api_obj_class.endpoint in ('pods', 'nodes') and not rel_url.query.join: %} 119 |
120 | Show CPU/Memory Usage 121 |
122 | {% endif %} 123 | 124 | 125 | 126 | 127 | {% if table.obj.clusters|length > 1: %} 128 | 129 | {% endif %} 130 | {% if is_all_namespaces: %} 131 | 132 | {% endif %} 133 | {% for column in table.columns: %} 134 | 141 | {% endfor %} 142 | 149 | {% if object_links[table.api_obj_class.endpoint]: %} 150 | 151 | {% endif %} 152 | 153 | 154 | 155 | {% for row in table.rows: %} 156 | 157 | {% if table.obj.clusters|length > 1: %} 158 | 159 | {% endif %} 160 | {% if is_all_namespaces: %} 161 | 162 | {% endif %} 163 | {% for cell in row.cells: %} 164 | {% if table.columns[loop.index0].name == 'Name': %} 165 | {% if row.object.metadata.namespace: %} 166 | 167 | {% else: %} 168 | 169 | {% endif %} 170 | {% else: %} 171 | 188 | {% endif %} 189 | {% endfor %} 190 | 191 | {% if object_links[table.api_obj_class.endpoint]: %} 192 | 199 | {% endif %} 200 | 201 | {% else: %} 202 | 203 | 204 | 205 | {% endfor %} 206 | 207 |
ClusterNamespace{{ column.name }} 135 | {% if rel_url.query.sort == column.name: %} 136 | 137 | {% elif rel_url.query.sort == column.name + ':desc': %} 138 | 139 | {% endif %} 140 | Created 143 | {% if rel_url.query.sort == 'Created': %} 144 | 145 | {% elif rel_url.query.sort == 'Created:desc': %} 146 | 147 | {% endif %} 148 |
{{ row.cluster.name }}{{ row.object.metadata.namespace }}{{ cell }}{{ cell }} 176 | {% if table.columns[loop.index0].label and table.columns[loop.index0].label != '*': %} 177 | {{ cell if cell is not none}} 178 | {% elif table.columns[loop.index0].name == 'Node': %} 179 | {{ cell if cell is not none}} 180 | {% elif table.columns[loop.index0].name == 'CPU Usage': %} 181 | {{ cell|cpu if cell is not none }} 182 | {% elif table.columns[loop.index0].name == 'Memory Usage': %} 183 | {{ cell|memory('MiB') if cell is not none }} MiB 184 | {% else: %} 185 | {{ cell if cell is not none}} 186 | {% endif %} 187 | {{ row.object.metadata.creationTimestamp.replace('T', ' ').replace('Z', '') }} 193 | {% for link in object_links[table.api_obj_class.endpoint]: %} 194 | 195 | 196 | 197 | {% endfor %} 198 |
No {{ table.api_obj_class.kind }} objects {% if namespace and not is_all_namespaces:%} in namespace "{{ namespace }}"{% endif %} found.
208 | 209 | {% endfor %} 210 | 211 |
212 | {% if namespace and plural != 'namespaces' and not is_all_namespaces: %} 213 |

Show {{ plural }} across all namespaces

214 | {% endif %} 215 | 216 |

Found {{ list_total_rows }} row{{ 's' if list_total_rows != 1 }} for {{ list_resource_types|length }} resource type{{ 's' if list_resource_types|length != 1}} in {{ list_clusters|length }} cluster{{ 's' if list_clusters|length != 1 }} in {{ '%.3f'|format(list_duration) }} seconds.

217 |
218 | 219 | {% for cluster_name, errors in list_errors.items(): %} 220 |
221 |
222 |

Error{{ 's' if errors|length > 1 }} for cluster {{ cluster_name }}

223 |
224 |
225 | {% for error in errors: %} 226 |

Failed to search {{ error.resource_type }}: {{ error.exception }}

227 | {% endfor %} 228 |
229 |
230 | {% endfor %} 231 | 232 | {% endblock %} 233 | -------------------------------------------------------------------------------- /kube_web/templates/resource-logs.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ resource.name }} ({{resource.kind}}{% if namespace: %} in {{namespace}}{% endif %}){% endblock %} 3 | {% block content %} 4 | 12 | 13 |

{{ resource.name }}

14 |
15 | 20 |
21 | 22 | {% if not show_container_logs: %} 23 | 24 |
25 |
26 |

Container Logs Disabled

27 |
28 |
29 | Container logs are not shown as they were disabled in Kubernetes Web View. Enable them via the "--show-container-logs" kube-web-view command line option. 30 |
31 |
32 | 33 | {% else: %} 34 | 35 |
36 |
37 | Get last log lines {% if all_containers|length > 2: %} of {{container_name}} container {% else: %} per container {% endif %}for {{ pods|length }} pods 38 | and filter by 39 | 40 | 41 |
42 |
43 | 44 | {% if all_container_names|length > 2: %} 45 |
46 | 55 |
56 | {% endif %} 57 | 58 |
59 | {% for log in logs: %}{{ log[1] }} {% if not container_name: %}{{ log[3] }} {% endif %}{{ log[0] }}{{ '\n' }}{% endfor %}
60 | {% if filter_text and not logs: %}No matching logs found. Please note that the filter text is case sensitive!{% endif %}
61 | 
62 | 63 | {% endif %} 64 | 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /kube_web/templates/resource-types.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Resource Types{% endblock %} 3 | {% block content %} 4 | 15 | 16 |

Resource Types

17 |
18 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for _type in resource_types: %} 32 | 33 | 34 | {% if namespace: %} 35 | 36 | {% else: %} 37 | 38 | {% endif %} 39 | 40 | 41 | 42 | {% endfor %} 43 |
KindPluralVersionPreferred?
{{ _type.kind }}{{ _type.endpoint }}{{ _type.endpoint }}{{ _type.version }}{{ "Yes" if preferred_api_versions.get(_type.endpoint) == _type.version }}
44 | 45 | 46 | 47 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /kube_web/templates/resource-view.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ resource.name }} ({{resource.kind}}{% if namespace and resource.kind != 'Namespace': %} in {{namespace}}{% endif %}){% endblock %} 3 | {% block content %} 4 | 16 | 17 |

{{ resource.name }} 18 | 19 | 27 | 28 | 29 | {% for link in links: %} 30 | 33 | 34 | 35 | {% endfor %} 36 | 37 | 38 | created {{ resource.metadata.creationTimestamp.replace('T', ' ').replace('Z', '') }}, 39 | version {{ resource.metadata.resourceVersion }} 40 | 41 |

42 |
43 |
    44 |
  • Default
  • 45 |
  • YAML
  • 46 | {% if resource.kind in ('Pod', 'Deployment', 'ReplicaSet', 'DaemonSet', 'StatefulSet'): %} 47 |
  • Logs
  • 48 | {% endif %} 49 |
50 |
51 | 52 | {% if view == 'yaml': %} 53 | {% include "partials/yaml.html" %} 54 | {% else: %} 55 |
56 | {% for key, val in resource.labels.items()|sort: %} 57 | {% if namespace: %} 58 | {{ key }}: {{ val }} 59 | {% else: %} 60 | {{ key }}: {{ val }} 61 | {% endif %} 62 | {% endfor %} 63 |
64 | 65 |
66 | {% for key, val in resource.annotations.items()|sort: %} 67 | {{ key }}: {{ val|truncate(40) }} 68 | {% endfor %} 69 |
70 | 71 | {% if owners: %} 72 |
73 | Owner{{ 's' if owners|length > 1 }}: 74 | {% for owner in owners: %} 75 | {% if owner.namespaced: %} 76 | {{ owner.name }} ({{ owner.class.kind }}) 77 | {% else: %} 78 | {{ owner.name }} ({{ owner.class.kind }}) 79 | {% endif %} 80 | {% endfor %} 81 |
82 | {% endif %} 83 | 84 | {% for key, val in resource.obj.items()|sort: %} 85 | {% if key not in ('metadata', 'apiVersion', 'kind'): %} 86 | 87 |
88 |

{{ key|capitalize }}

89 |
90 | {{ val|yaml|highlight|safe }} 91 |
92 |
93 | {% endif %} 94 | {% endfor %} 95 | 96 | {% if resource.kind == "Namespace": %} 97 | 100 | 103 | {% endif %} 104 | 105 | 106 | {% if table: %} 107 |
108 |

Pods

109 | 110 | 111 | {% if not namespace: %} 112 | 113 | {% endif %} 114 | {% for column in table.columns: %} 115 | 116 | {% endfor %} 117 | 118 | 119 | {% for row in table.rows: %} 120 | 121 | {% if not namespace: %} 122 | 123 | {% endif %} 124 | {% for cell in row.cells: %} 125 | {% if loop.first: %} 126 | {% if row.object.metadata.namespace: %} 127 | 128 | {% else: %} 129 | 130 | {% endif %} 131 | {% else: %} 132 | 143 | {% endif %} 144 | {% endfor %} 145 | 146 | 147 | {% else: %} 148 | 149 | 150 | 151 | {% endfor %} 152 |
Namespace{{ column.name }}Created
{{ row.object.metadata.namespace }}{{ cell }}{{ cell }} 137 | {% if table.columns[loop.index0].name == 'Node': %} 138 | {{ cell if cell is not none}} 139 | {% else: %} 140 | {{ cell if cell is not none}} 141 | {% endif %} 142 | {{ row.object.metadata.creationTimestamp.replace('T', ' ').replace('Z', '') }}
No {{ table.api_obj_class.kind }} objects {% if namespace and not is_all_namespaces:%} in namespace "{{ namespace }}"{% endif %} found.
153 |
154 | {% endif %} 155 | 156 |
157 | {% include "partials/events.html" %} 158 |
159 | 160 | {% endif %} 161 | 162 | {% endblock %} 163 | -------------------------------------------------------------------------------- /kube_web/templates/search.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Search{% endblock %} 3 | {% block content %} 4 | 21 | 22 |

Search

23 | 24 |
25 | 26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 |

34 | 35 | 36 | 37 | 38 |

39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 |

52 | {% for _type, kind in searchable_resource_types.items()|sort: %} 53 | 54 | {% endfor %} 55 | 56 | 57 | 58 | 59 | unselect all 60 | 61 |

62 |
63 |
64 |
65 |
66 | 67 | 68 | {% for result in search_results: %} 69 | 70 |
71 |

{{ result.title }} ({{ result.kind }})

72 |

{{ result.link }}

73 | {% if result.created or result.matches: %} 74 |

75 | {% if result.created: %} 76 | Created: {{ result.created.replace('T', ' ').replace('Z', '') }} 77 | {% endif %} 78 | {% for pre, highlight, post in result.matches: %} 79 | {{ pre }}{{ highlight }}{{ post }} 80 | {% endfor %} 81 |

82 | {% endif %} 83 |

84 | {% for key, val in result.labels.items()|sort: %} 85 | {{ key }}: {{ val }} 86 | {% endfor %} 87 |

88 |
89 | 90 | {% else: %} 91 | 92 | {% if search_query: %} 93 | 94 |
95 |

No results found for "{{ search_query }}".

96 |
97 | 98 | {% endif %} 99 | 100 | {% endfor %} 101 | 102 | {% if search_query: %} 103 |
104 | 105 | {% if not is_all_namespaces: %} 106 |

Repeat search across all namespaces

107 | {% endif %} 108 | 109 |

{{ search_results|length }} result{{ 's' if search_results|length != 1 }} found. Searched {{ resource_types|length }} resource types in {{ search_clusters|length }} cluster{{ 's' if search_clusters|length > 1 }} in {{ '%.3f'|format(search_duration) }} seconds.

110 | 111 |
112 | {% endif %} 113 | 114 | 115 | {% for cluster_name, errors in search_errors.items(): %} 116 |
117 |
118 |

Error{{ 's' if errors|length > 1 }} for cluster {{ cluster_name }}

119 |
120 |
121 | {% for error in errors: %} 122 |

Failed to search {{ error.resource_type }}: {{ error.exception }}

123 | {% endfor %} 124 |
125 |
126 | {% endfor %} 127 | 128 | {% endblock %} 129 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "kube-web-view" 3 | version = "20.6.0" 4 | description = "Kubernetes Web View allows to list and view all Kubernetes resources (incl. CRDs) with permalink-friendly URLs in a plain-HTML frontend" 5 | authors = ["Henning Jacobs "] 6 | license = "GPL-3.0+" 7 | readme = "README.md" 8 | homepage = "https://kube-web-view.readthedocs.io/" 9 | documentation = "https://kube-web-view.readthedocs.io/" 10 | repository = "https://codeberg.org/hjacobs/kube-web-view" 11 | packages = [ 12 | { include = "kube_web" } 13 | ] 14 | 15 | [tool.poetry.scripts] 16 | kube-web-view = 'kube_web:main.main' 17 | 18 | [tool.poetry.dependencies] 19 | python = "^3.7" 20 | pykube-ng = ">=19.9.2" 21 | Jinja2 = "^2.10" 22 | aiohttp-jinja2 = "^1.1" 23 | Pygments = "^2.4" 24 | aiohttp_session = {version = "^2.7", extras = ["secure"]} 25 | aioauth-client = "^0.17.3" 26 | aiohttp_remotes = "^0.1.2" 27 | jmespath = "^0.9.4" 28 | [tool.poetry.dev-dependencies] 29 | pytest = "^5.0" 30 | pytest-cov = "^2.7" 31 | Sphinx = "^2.1" 32 | sphinx-rtd-theme = "^0.4.3" 33 | requests-html = "^0.10.0" 34 | pytest-kind = ">=19.9.1" 35 | mypy = "^0.760" 36 | pre-commit = "^1.21.0" 37 | [build-system] 38 | requires = ["poetry>=0.12"] 39 | build-backend = "poetry.masonry.api" 40 | -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hjacobs/kube-web-view/f9816bcf9eaea7ec20697e36b1d8de024662666e/tests/e2e/__init__.py -------------------------------------------------------------------------------- /tests/e2e/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from functools import partial 4 | from pathlib import Path 5 | from tempfile import NamedTemporaryFile 6 | from typing import Generator 7 | 8 | import yaml 9 | from pytest import fixture 10 | from requests_html import HTMLSession 11 | 12 | kind_cluster_name = "kube-web-view-e2e" 13 | 14 | 15 | @fixture(scope="session") 16 | def cluster(kind_cluster) -> Generator[dict, None, None]: 17 | docker_image = os.getenv("TEST_IMAGE") 18 | kind_cluster.load_docker_image(docker_image) 19 | 20 | logging.info("Deploying kube-web-view ...") 21 | deployment_manifests_path = Path(__file__).parent / "deployment.yaml" 22 | 23 | kubectl = kind_cluster.kubectl 24 | 25 | with NamedTemporaryFile(mode="w+") as tmp: 26 | with deployment_manifests_path.open() as f: 27 | resources = list(yaml.safe_load_all(f)) 28 | dep = resources[-1] 29 | assert ( 30 | dep["kind"] == "Deployment" and dep["metadata"]["name"] == "kube-web-view" 31 | ) 32 | dep["spec"]["template"]["spec"]["containers"][0]["image"] = docker_image 33 | yaml.dump_all(documents=resources, stream=tmp) 34 | kubectl("apply", "-f", tmp.name) 35 | 36 | logging.info("Deploying other test resources ...") 37 | kubectl("apply", "-f", str(Path(__file__).parent / "test-resources.yaml")) 38 | 39 | logging.info("Waiting for rollout ...") 40 | kubectl("rollout", "status", "deployment/kube-web-view") 41 | 42 | with kind_cluster.port_forward("service/kube-web-view", 80) as port: 43 | url = f"http://localhost:{port}/" 44 | yield {"url": url} 45 | 46 | 47 | @fixture(scope="session") 48 | def populated_cluster(cluster): 49 | return cluster 50 | 51 | 52 | @fixture(scope="session") 53 | def session(populated_cluster): 54 | 55 | url = populated_cluster["url"].rstrip("/") 56 | 57 | s = HTMLSession() 58 | 59 | def new_request(prefix, f, method, url, *args, **kwargs): 60 | return f(method, prefix + url, *args, **kwargs) 61 | 62 | s.request = partial(new_request, url, s.request) 63 | return s 64 | -------------------------------------------------------------------------------- /tests/e2e/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | namespace: "default" 6 | name: kube-web-view-account 7 | --- 8 | apiVersion: rbac.authorization.k8s.io/v1beta1 9 | kind: ClusterRole 10 | metadata: 11 | name: kube-web-view-cluster-role 12 | rules: 13 | 14 | - apiGroups: 15 | - '*' 16 | resources: 17 | - '*' 18 | verbs: [list, get] 19 | - nonResourceURLs: 20 | - '*' 21 | verbs: [list, get] 22 | 23 | --- 24 | apiVersion: rbac.authorization.k8s.io/v1beta1 25 | kind: ClusterRoleBinding 26 | metadata: 27 | name: kube-web-view-rolebinding-cluster 28 | roleRef: 29 | apiGroup: rbac.authorization.k8s.io 30 | kind: ClusterRole 31 | name: kube-web-view-cluster-role 32 | subjects: 33 | - kind: ServiceAccount 34 | name: kube-web-view-account 35 | namespace: "default" 36 | --- 37 | apiVersion: v1 38 | kind: Service 39 | metadata: 40 | name: kube-web-view 41 | spec: 42 | selector: 43 | application: kube-web-view 44 | type: ClusterIP 45 | ports: 46 | - port: 80 47 | protocol: TCP 48 | targetPort: 8080 49 | --- 50 | apiVersion: apps/v1 51 | kind: Deployment 52 | metadata: 53 | name: kube-web-view 54 | labels: 55 | application: kube-web-view 56 | spec: 57 | replicas: 1 58 | selector: 59 | matchLabels: 60 | application: kube-web-view 61 | template: 62 | metadata: 63 | labels: 64 | application: kube-web-view 65 | spec: 66 | serviceAccountName: kube-web-view-account 67 | containers: 68 | - name: kube-web-view-container 69 | image: TO_BE_REPLACED_BY_E2E_TEST 70 | imagePullPolicy: IfNotPresent # For our E2E tests. 71 | args: 72 | - --show-container-logs 73 | - --object-links=pods=#cluster={cluster};namespace={namespace};name={name} 74 | - --exclude-namespaces=.*forbidden.* 75 | - --resource-view-prerender-hook=kube_web.example_hooks.resource_view_prerender 76 | env: [] 77 | -------------------------------------------------------------------------------- /tests/e2e/test-resources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: wrong-container-image 5 | labels: 6 | app: wrong-container-image 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: wrong-container-image 12 | template: 13 | metadata: 14 | labels: 15 | app: wrong-container-image 16 | e2e: wrong-container-image 17 | my-pod-label: my-pod-label-value 18 | spec: 19 | containers: 20 | - name: mycontainer 21 | image: hjacobs/wrong-container-image:0.1 22 | --- 23 | apiVersion: v1 24 | kind: Secret 25 | metadata: 26 | name: test-secret 27 | data: 28 | my-secret-key: c2VjcmV0LWNvbnRlbnQK 29 | --- 30 | # deploy some unavailable APIService 31 | # this is to test that kube-web-view does not crash 32 | # see https://codeberg.org/hjacobs/kube-web-view/issues/64 33 | apiVersion: apiregistration.k8s.io/v1 34 | kind: APIService 35 | metadata: 36 | labels: 37 | app: test 38 | name: v1beta1.test.example.org 39 | spec: 40 | group: test.example.org 41 | groupPriorityMinimum: 100 42 | insecureSkipTLSVerify: true 43 | service: 44 | name: test.example.org 45 | namespace: default 46 | version: v1beta1 47 | versionPriority: 100 48 | --- 49 | apiVersion: v1 50 | kind: Namespace 51 | metadata: 52 | name: my-forbidden-namespace 53 | --- 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | metadata: 57 | name: deployment-in-forbbiden-ns 58 | namespace: my-forbidden-namespace 59 | spec: 60 | replicas: 0 61 | selector: 62 | matchLabels: 63 | app: deployment-in-forbidden-ns 64 | template: 65 | metadata: 66 | labels: 67 | app: deployment-in-forbidden-ns 68 | spec: 69 | containers: 70 | - name: mycontainer 71 | image: nginx 72 | --- 73 | apiVersion: v1 74 | kind: Namespace 75 | metadata: 76 | name: deployment-with-init-container 77 | --- 78 | apiVersion: apps/v1 79 | kind: Deployment 80 | metadata: 81 | name: deployment-with-init-container 82 | namespace: deployment-with-init-container 83 | spec: 84 | replicas: 1 85 | selector: 86 | matchLabels: 87 | app: deployment-with-init-container 88 | template: 89 | metadata: 90 | labels: 91 | app: deployment-with-init-container 92 | spec: 93 | initContainers: 94 | - name: busybox 95 | image: busybox 96 | command: ["sh", "-c", "echo 'MESSAGE FROM INIT CONTAINER!'"] 97 | - name: second-container 98 | image: busybox 99 | command: ["sh", "-c", "echo 'MESSAGE FROM SECOND INIT CONTAINER!'"] 100 | containers: 101 | - name: nginx 102 | image: nginx 103 | -------------------------------------------------------------------------------- /tests/e2e/test_preferences.py: -------------------------------------------------------------------------------- 1 | def test_preferences(session): 2 | response = session.get("/preferences") 3 | response.raise_for_status() 4 | select = response.html.find("main select", first=True) 5 | options = [o.text for o in select.find("option")] 6 | assert options == ["darkly", "default", "flatly", "slate", "superhero"] 7 | -------------------------------------------------------------------------------- /tests/e2e/test_search.py: -------------------------------------------------------------------------------- 1 | def test_search_form(session): 2 | response = session.get("/search") 3 | response.raise_for_status() 4 | search_results = response.html.find(".search-result") 5 | assert len(search_results) == 0 6 | assert "No results found for " not in response.text 7 | 8 | 9 | def test_search_cluster(session): 10 | response = session.get("/search?q=local") 11 | response.raise_for_status() 12 | title = response.html.find(".search-result h3", first=True) 13 | assert title.text == "local (Cluster)" 14 | 15 | 16 | def test_search_namespace(session): 17 | response = session.get("/search?q=default") 18 | response.raise_for_status() 19 | title = response.html.find(".search-result h3", first=True) 20 | assert title.text == "default (Namespace)" 21 | 22 | 23 | def test_search_by_label(session): 24 | response = session.get("/search?q=application=kube-web-view") 25 | response.raise_for_status() 26 | title = response.html.find(".search-result h3", first=True) 27 | assert title.text == "kube-web-view (Deployment)" 28 | 29 | 30 | def test_no_results_found(session): 31 | response = session.get("/search?q=stringwithnoresults") 32 | response.raise_for_status() 33 | search_results = response.html.find(".search-result") 34 | assert len(search_results) == 0 35 | p = response.html.find("main .content p", first=True) 36 | assert p.text == 'No results found for "stringwithnoresults".' 37 | 38 | 39 | def test_search_non_standard_resource_type(session): 40 | response = session.get("/search?q=whatever&type=podsecuritypolicies") 41 | response.raise_for_status() 42 | # check that the type was added as checkbox 43 | labels = response.html.find("label.checkbox") 44 | assert "PodSecurityPolicy" in [label.text for label in labels] 45 | 46 | 47 | def test_search_container_image_match_highlight(session): 48 | response = session.get("/search?q=hjacobs/wrong-container-image:&type=deployments") 49 | response.raise_for_status() 50 | match = response.html.find(".search-result .match", first=True) 51 | assert ( 52 | 'hjacobs/wrong-container-image:0.1' 53 | == match.html 54 | ) 55 | 56 | 57 | def test_search_forbidden_namespace(session): 58 | response = session.get("/search?q=forbidden&type=deployments") 59 | response.raise_for_status() 60 | matches = list(response.html.find("main .search-result")) 61 | assert len(matches) == 0 62 | -------------------------------------------------------------------------------- /tests/e2e/test_view.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | from .utils import check_links 4 | 5 | 6 | def test_view_namespace(session): 7 | response = session.get("/clusters/local/namespaces/default") 8 | response.raise_for_status() 9 | title = response.html.find("title", first=True) 10 | assert title.text == "default (Namespace) - Kubernetes Web View" 11 | 12 | 13 | def test_view_namespace_trailing_slash(session): 14 | response = session.get("/clusters/local/namespaces/default/") 15 | response.raise_for_status() 16 | title = response.html.find("title", first=True) 17 | assert title.text == "default (Namespace) - Kubernetes Web View" 18 | 19 | 20 | def test_view_namespace_forbidden(session): 21 | response = session.get("/clusters/local/namespaces/my-forbidden-namespace") 22 | assert response.status_code == 403 23 | 24 | 25 | def test_view_namespaced_resource(session): 26 | response = session.get( 27 | "/clusters/local/namespaces/default/deployments/kube-web-view" 28 | ) 29 | response.raise_for_status() 30 | assert "application=kube-web-view" in response.text 31 | 32 | response = session.get("/clusters/local/namespaces/default/services/kube-web-view") 33 | response.raise_for_status() 34 | assert "ClusterIP" in response.text 35 | 36 | 37 | def test_view_namespaced_resource_forbidden(session): 38 | response = session.get( 39 | "/clusters/local/namespaces/my-forbidden-namespace/deployments/kube-web-view" 40 | ) 41 | assert response.status_code == 403 42 | 43 | 44 | def test_cluster_not_found(session): 45 | response = session.get( 46 | "/clusters/no-such-cluster/namespaces/default/deployments/kube-web-view" 47 | ) 48 | assert response.status_code == 404 49 | assert "cluster not found" in response.text 50 | 51 | 52 | def test_object_not_found(session): 53 | response = session.get( 54 | "/clusters/local/namespaces/default/deployments/no-such-deploy" 55 | ) 56 | assert response.status_code == 404 57 | assert "object does not exist" in response.text 58 | 59 | 60 | def test_logs(session): 61 | response = session.get( 62 | "/clusters/local/namespaces/default/deployments/kube-web-view", 63 | headers={"User-Agent": "TEST-LOGS-USER-AGENT"}, 64 | ) 65 | response.raise_for_status() 66 | response = session.get( 67 | "/clusters/local/namespaces/default/deployments/kube-web-view/logs" 68 | ) 69 | response.raise_for_status() 70 | assert "TEST-LOGS-USER-AGENT" in response.text 71 | 72 | 73 | def test_logs_from_init_container(session): 74 | response = session.get( 75 | "/clusters/local/namespaces/deployment-with-init-container/deployments/deployment-with-init-container/logs" 76 | ) 77 | response.raise_for_status() 78 | assert "MESSAGE FROM INIT CONTAINER!" in response.text 79 | 80 | 81 | def test_logs_from_single_container(session): 82 | response = session.get( 83 | "/clusters/local/namespaces/deployment-with-init-container/deployments/deployment-with-init-container/logs", 84 | params={"container": "second-container"}, 85 | ) 86 | response.raise_for_status() 87 | main = response.html.find("main pre", first=True) 88 | assert "MESSAGE FROM INIT CONTAINER!" not in main.text 89 | assert "MESSAGE FROM SECOND INIT CONTAINER!" in main.text 90 | 91 | 92 | def test_logs_filter(session): 93 | response = session.get( 94 | "/clusters/local/namespaces/deployment-with-init-container/deployments/deployment-with-init-container/logs", 95 | params={"filter": "SECOND"}, 96 | ) 97 | response.raise_for_status() 98 | main = response.html.find("main pre", first=True) 99 | assert "MESSAGE FROM INIT CONTAINER!" not in main.text 100 | assert "MESSAGE FROM SECOND INIT CONTAINER!" in main.text 101 | 102 | 103 | def test_hide_secret_contents(session): 104 | response = session.get("/clusters/local/namespaces/default/secrets/test-secret") 105 | response.raise_for_status() 106 | # echo 'secret-content' | base64 107 | assert "c2VjcmV0LWNvbnRlbnQK" not in response.text 108 | assert "**SECRET-CONTENT-HIDDEN-BY-KUBE-WEB-VIEW**" in response.text 109 | 110 | 111 | def test_download_yaml(session): 112 | response = session.get( 113 | "/clusters/local/namespaces/default/deployments/kube-web-view?download=yaml" 114 | ) 115 | response.raise_for_status() 116 | data = yaml.safe_load(response.text) 117 | assert data["kind"] == "Deployment" 118 | assert data["metadata"]["name"] == "kube-web-view" 119 | 120 | 121 | def test_download_secret_yaml(session): 122 | response = session.get( 123 | "/clusters/local/namespaces/default/secrets/test-secret?download=yaml" 124 | ) 125 | response.raise_for_status() 126 | data = yaml.safe_load(response.text) 127 | assert data["kind"] == "Secret" 128 | assert data["metadata"]["name"] == "test-secret" 129 | assert data["data"]["my-secret-key"] == "**SECRET-CONTENT-HIDDEN-BY-KUBE-WEB-VIEW**" 130 | 131 | 132 | def test_node_shows_pods(session): 133 | response = session.get("/clusters/local/nodes/kube-web-view-e2e-control-plane") 134 | response.raise_for_status() 135 | links = response.html.find("main table a") 136 | # check that our kube-web-view pod (dynamic name) is linked from the node page 137 | assert "/clusters/local/namespaces/default/pods/kube-web-view-" in " ".join( 138 | l.attrs["href"] for l in links 139 | ) 140 | 141 | 142 | def test_owner_links(session): 143 | response = session.get( 144 | "/clusters/local/namespaces/default/pods?selector=application=kube-web-view" 145 | ) 146 | response.raise_for_status() 147 | pod_link = response.html.find("main table td a", first=True) 148 | url = pod_link.attrs["href"] 149 | assert url.startswith("/clusters/local/namespaces/default/pods/kube-web-view-") 150 | response = session.get(url) 151 | response.raise_for_status() 152 | check_links(response, session) 153 | 154 | links = response.html.find("main a") 155 | found_link = None 156 | for link in links: 157 | if link.text.endswith(" (ReplicaSet)"): 158 | found_link = link 159 | break 160 | assert found_link is not None 161 | assert found_link.attrs["href"].startswith( 162 | "/clusters/local/namespaces/default/replicasets/kube-web-view-" 163 | ) 164 | 165 | 166 | def test_object_links(session): 167 | response = session.get( 168 | "/clusters/local/namespaces/default/pods?selector=application=kube-web-view" 169 | ) 170 | response.raise_for_status() 171 | pod_link = response.html.find("main table td a", first=True) 172 | url = pod_link.attrs["href"] 173 | assert url.startswith("/clusters/local/namespaces/default/pods/kube-web-view-") 174 | response = session.get(url) 175 | response.raise_for_status() 176 | check_links(response, session) 177 | 178 | link = response.html.find("main h1 a.is-primary", first=True) 179 | assert link.attrs["href"].startswith( 180 | "#cluster=local;namespace=default;name=kube-web-view-" 181 | ) 182 | 183 | 184 | def test_link_added_by_prerender_hook(session): 185 | response = session.get( 186 | "/clusters/local/namespaces/default/deployments/kube-web-view" 187 | ) 188 | response.raise_for_status() 189 | check_links(response, session) 190 | 191 | link = response.html.find("main h1 a.is-link", first=True) 192 | assert link.attrs["href"].startswith("#this-is-a-custom-link") 193 | 194 | 195 | def test_pod_with_node_owner(session): 196 | response = session.get( 197 | "/clusters/local/namespaces/kube-system/pods?selector=component=kube-apiserver" 198 | ) 199 | response.raise_for_status() 200 | pod_link = response.html.find("main table td a", first=True) 201 | url = pod_link.attrs["href"] 202 | assert url.startswith("/clusters/local/namespaces/kube-system/pods/kube-apiserver-") 203 | response = session.get(url) 204 | response.raise_for_status() 205 | check_links(response, session) 206 | 207 | links = response.html.find("main a") 208 | found_link = None 209 | for link in links: 210 | if link.text.endswith(" (Node)"): 211 | found_link = link 212 | break 213 | assert found_link is not None 214 | assert found_link.attrs["href"].startswith( 215 | "/clusters/local/nodes/kube-web-view-e2e-control-plane" 216 | ) 217 | -------------------------------------------------------------------------------- /tests/e2e/test_web.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_generic_404_error(populated_cluster): 5 | url = populated_cluster["url"].rstrip("/") 6 | response = requests.get(f"{url}/this-page-does-not-exist") 7 | assert response.status_code == 404 8 | assert "Not Found" in response.text 9 | # check that our template is used 10 | assert "Kubernetes Web View" in response.text 11 | -------------------------------------------------------------------------------- /tests/e2e/utils.py: -------------------------------------------------------------------------------- 1 | # rolebindings cannot be listed 2 | LINKS_TO_IGNORE = [ 3 | "/clusters/local/clusterrolebindings", 4 | "/clusters/local/clusterroles", 5 | "/clusters/local/componentstatuses", 6 | "/clusters/local/namespaces/default/rolebindings", 7 | "/clusters/local/namespaces/default/roles", 8 | ] 9 | 10 | 11 | def check_links(response, session, ignore=None): 12 | for link in response.html.links: 13 | if link.startswith("/") and link not in LINKS_TO_IGNORE: 14 | if ignore and link in ignore: 15 | continue 16 | r = session.get(link) 17 | r.raise_for_status() 18 | -------------------------------------------------------------------------------- /tests/unit/test_cluster_manager.py: -------------------------------------------------------------------------------- 1 | from kube_web.cluster_manager import sanitize_cluster_name 2 | 3 | 4 | def test_sanitize_cluster_name(): 5 | assert sanitize_cluster_name("foo.bar") == "foo.bar" 6 | assert sanitize_cluster_name("my-cluster") == "my-cluster" 7 | assert sanitize_cluster_name("a b") == "a:b" 8 | assert sanitize_cluster_name("https://srcco.de") == "https:::srcco.de" 9 | -------------------------------------------------------------------------------- /tests/unit/test_jinja2_filters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from kube_web.jinja2_filters import age_color 4 | from kube_web.jinja2_filters import pluralize 5 | from kube_web.jinja2_filters import yaml 6 | 7 | 8 | def test_yaml(): 9 | assert yaml({}) == "{}\n" 10 | 11 | 12 | def test_pluralize(): 13 | assert pluralize("test") == "tests" 14 | assert pluralize("Ingress") == "Ingresses" 15 | assert pluralize("NetworkPolicy") == "NetworkPolicies" 16 | 17 | 18 | def test_age_color(): 19 | now = datetime.datetime.now() 20 | dt = now - datetime.timedelta(days=2) 21 | assert age_color(now, days=1) == "#00cf46" 22 | # older timestamps should be default bulma text color 23 | assert age_color(dt, days=1) == "#363636" 24 | -------------------------------------------------------------------------------- /tests/unit/test_joins.py: -------------------------------------------------------------------------------- 1 | from kube_web.joins import generate_name_from_spec 2 | 3 | 4 | def test_generate_name_from_spec(): 5 | assert generate_name_from_spec("a.b") == "A B" 6 | assert generate_name_from_spec(" a.b ") == "A B" 7 | assert ( 8 | generate_name_from_spec(" a[1].containers[*].Image ") == "A 1 Containers Image" 9 | ) 10 | assert ( 11 | generate_name_from_spec('metadata.annotations."foo"') 12 | == "Metadata Annotations Foo" 13 | ) 14 | -------------------------------------------------------------------------------- /tests/unit/test_kubernetes.py: -------------------------------------------------------------------------------- 1 | from kube_web.kubernetes import parse_resource 2 | 3 | 4 | def test_parse_resource(): 5 | assert parse_resource("500m") == 0.5 6 | -------------------------------------------------------------------------------- /tests/unit/test_main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import MagicMock 3 | 4 | from kube_web.cluster_discovery import KubeconfigDiscoverer 5 | from kube_web.main import main 6 | from kube_web.main import parse_args 7 | 8 | 9 | def test_parse_clusters(): 10 | args = parse_args(["--clusters=foo=https://foo;bar=https://bar"]) 11 | assert args.clusters == {"foo": "https://foo", "bar": "https://bar"} 12 | 13 | 14 | def test_parse_sidebar_resource_types(): 15 | args = parse_args(["--sidebar-resource-types=Main=nodes,pods;CRDs=foos,bars"]) 16 | assert args.sidebar_resource_types == { 17 | "Main": ["nodes", "pods"], 18 | "CRDs": ["foos", "bars"], 19 | } 20 | 21 | 22 | def test_parse_default_custom_columns(): 23 | args = parse_args( 24 | [ 25 | "--default-custom-columns=pods=A=metadata.name;B=spec;;deployments=Strategy=spec.strategy" 26 | ] 27 | ) 28 | assert args.default_custom_columns == { 29 | "pods": "A=metadata.name;B=spec", 30 | "deployments": "Strategy=spec.strategy", 31 | } 32 | 33 | 34 | def test_use_kubeconfig_path_if_passed(monkeypatch, tmpdir): 35 | def get_app(cluster_manager, config): 36 | # make sure we use the passed kubeconfig 37 | assert isinstance(cluster_manager.discoverer, KubeconfigDiscoverer) 38 | return None 39 | 40 | monkeypatch.setattr("aiohttp.web.run_app", lambda *args, port, handle_signals: None) 41 | monkeypatch.setattr("kube_web.main.get_app", get_app) 42 | 43 | kubeconfig_path = Path(str(tmpdir)) / "my-kubeconfig" 44 | with kubeconfig_path.open("w") as fd: 45 | fd.write("{contexts: []}") 46 | 47 | # fake successful service account discovery ("in-cluster") 48 | monkeypatch.setattr("kube_web.main.ServiceAccountClusterDiscoverer", MagicMock()) 49 | main([f"--kubeconfig-path={kubeconfig_path}"]) 50 | -------------------------------------------------------------------------------- /tests/unit/test_selector.py: -------------------------------------------------------------------------------- 1 | from kube_web.selector import parse_selector 2 | from kube_web.selector import selector_matches 3 | 4 | 5 | def test_parse_selector(): 6 | assert parse_selector("a=1") == {"a": "1"} 7 | 8 | 9 | def test_selector_matches(): 10 | assert selector_matches({"a!": ["1", "2"]}, {"a": "3"}) 11 | assert not selector_matches({"a!": ["1", "2"]}, {"a": "1"}) 12 | assert not selector_matches({"a!": ["1", "2"]}, {"a": "2"}) 13 | -------------------------------------------------------------------------------- /tests/unit/test_web.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from kube_web.web import is_allowed_namespace 4 | 5 | 6 | def test_is_allowed_namespace(): 7 | assert is_allowed_namespace("a", [], []) 8 | assert is_allowed_namespace("a", [re.compile("a")], []) 9 | assert is_allowed_namespace("a", [], [re.compile("b")]) 10 | assert not is_allowed_namespace("a", [re.compile("b")], []) 11 | assert not is_allowed_namespace("a", [], [re.compile("a")]) 12 | 13 | assert not is_allowed_namespace("default-foo", [re.compile("default")], []) 14 | --------------------------------------------------------------------------------