New Epinio pkg, and updates to dashboard (core, pkg process) (#5637)

* Doc

* v0.7.0

* v0.7.1

* v0.7.2

* Add creators

* v0.1.14

* v0.1.17

* Add Yarn link

* Tidy ups and typos

* Allow models to be loaded from plugins

* v0.1.15

* v0.1.18

* v0.6.6

* Improve readme, fix cyperss log msg

* v0.1.17

* v0.1.19

* v0.6.7

* v0.6.8

* Fix logo ref when in shell

* Fix lint issues

* Fix error in example

* Fix script to work on linux

* Add ability to revert moves without losing changes

* Fix bug with custom models in a plugin

* Fix build of UI packages

* Add dist-pkg to .eslintignore

* Pull out util from extend-router to prevent router being pulled in UI packages

* Update PLUGINS.md

* Remove duplicate dependency

* Reduce size of built UI packages

* Share codemirror. Other tidy ups

* Further improvements

* Tidy ups to support i18n in plugins

* Clen up add comments

* More clean-ups and comments added

* Rename from extension to plugin

* Missed file in rename

* v0.6.9

* TIdy ups following rename

* v0.1.20

* v0.1.21

* More refactor and tidy up

* v0.1.22

* v0.1.18

* v0.1.19

* v0.1.23

* v0.6.10

* Version Packages. Improve naming. Unload.

* v0.6.11

* v0.1.20

* v0.1.21

* v0.1.24

* v0.6.12

* v0.1.25

* v0.1.22

* v0.6.13

* Fix issues when plugin is builtin

* Add missing files

* Fix lint issues and watcher ignores

* Fixes following review

* v0.1.28

* v0.1.31

* v0.6.20

* Fix coer.js version

* Fix bug where plugins included via npm don't work

* Changes post merge

* Move plugins doc to dev guide, add note at top of README

* Update cypress version

* Add note about the reset --hard in rejig -d

* Fixes post merge

* Rename @ranch to @rancher and ad plugins

* Improve routes support, add package assets support

* Add uninstall hooks and pass interal opts

* Fix rejig script

* Minor fixes

* disable consent banner text overflow wrap

* add additional metadata to workload detail view

* minor css tweak

* Routing tweaks
- attempted to improve `addRoutes` typing, failed a lot
- improved typings in plugins.ts
- added "@pkg/*" entry and vue-shim (for importing components) to the pkg creator
- fixed some linting
- safely fail when Verdaccio isn't running
- fixed some typos

* Remove frontmatter-markdown-loader reference from nuxt-config

* Add annotation to stop upgrades for managed charts

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* Use correct name for Network Attachment Definition Resource (#5579)

* Fix heading levels in Account and API keys page (#5563)

* SortableTable Performance: Optimise row mouse over/leave handlers (#5550)

Co-authored-by: Richard Cox <richard.cox@suse.com>

* Keep the check for fleet bundle ID

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* Revert "Remove frontmatter-markdown-loader reference from nuxt-config"

This reverts commit 29ef6f2f7b.

* Exclude creators from dashboard build

* Revert "Exclude creators from dashboard build"

This reverts commit 8ede93ee7c.

* Ingore creators via tsconfig
- We're still susceptible with vue shims in multiple packages
- Need to determine why packages can't use shim from route
- Probably need to move `./vue-shim.d.ts` in to `./shell`. Needs testing with a pkg that imports a component

* warn on adding windows node to cutom cluster

* Display custom error message when login fails. (#5582)

* change rke2 upgradeStrategy to 1 concurrent controlplane and worker (#5580)

Co-authored-by: Alexandre Alves <aalves@AlexandresMBP2.lan>

* update endpoint display, fix ready status for daemonset type

* Fix Sortable Tble SSR (#5599)

* Sortable Table: Fix broken props to slot (#5600)

* expanded error check to include simple type

* fixed error check logic

* Fix async buton wobble with spinner (#5586)

* Add Pod restart count to workload detail page

* Can scale workload from workload detail page

* Enable source map in Nuxt/Webpack configuration for debugging (#5590)

* Move hardcoded values for roles in his own config file

* Allow to set default values also for Clusters and Namespaces

* Allow local cluster to be hidden

* Initialize always auth errors as empty list

* Add translation for snapshot group

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* Enable s3 storage in rke2 cluster config

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* Group by snapshot location

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* After rejig

* Prevent injection of malformed errors in the generic resource form component

* New Epinio pkg, and updates to dashboard (core, pkg process)

* Remove epinio-select

* correct @shell// reference

* Fix filtering issue
- store/index suffered a bad merge

* Add hide-local-cluster to the settings page

* Fix node table column sorting (#5611)

* Fix issue where private registry auth can be changed in view mode (#5617)

* Change title of support block on home page when custom link set (#5547)

* Tidying up

* Add feedback to the copy kubeconfig header button (#5628)

* Add feedback to the copy kubeconfig header button

* Minor improvement

* Get rke3 and k3s latest versions from settings (#5608)

* Revert previous change and avoid watching spoofed types

* Move the group-by string to computed prop

Signed-off-by: Phillip Rak <rak.phillip@gmail.com>

* Use computed instead of method, use Array.isArray instead of lodash isArray

* Ensure auth provider note appears consistently within form instead of new floating button section
- some auth providers already had this
- possibly better ux to add to top, but that area is already busy

* Fix bug where delay loading only worked on scroll with live columns (#5635)

* Inconsistencies with Cluster metrics across the UI (#5542)

* fix num pods being displayed on the homepage cluster list + set mem to gib for ram on homepage cluster list

* show total number of nodes rather that useful nodes in cluster dashboard view + remove reserved info for CPU and MEM in cluster table in homepage + fix pods usage in nodes list view + clear console logs

* cleanup

* correctly wire PodsUsage as a formatter + display N/A when usage is zero in nodes list values for CPU, MEM, PODS

* add loader icon to PodsUsage formatter

* add string translation to n/a in percentageBar formatter

* add delayedLoading to PodsUsage formatter

Co-authored-by: Alexandre Alves <aalves@Alexandres-MacBook-Pro-2.local>
Co-authored-by: Alexandre Alves <aalves@AlexandresMBP2.lan>

* Enable filtering by project name on project/namespaces paeg (#5636)

* Fixes post merge

* Rename core-store to dashboard-store
- contains TODO: RCs to resolve

* Tidy up/imporve nav hooks

* Fix some todo's, epinio routing product

* refactor detailtop

* Hide NeuVector product if `cattle-neuvector-system` does not exist
- As per request at https://github.com/rancher/dashboard/issues/5556#issuecomment-1098270110
- fixes #5556
- alternative to #5604

* Fix a number of areas where the sticky footer consumes large amounts of space
- Fixes #5643
- Mostly caused by non-standard ways the CruResource was being used

Effected Areas / Places to test
Note - Only need to visit pages, shouldn't need to make any changes to resources
- Cluster create/import screens (types, credentials (create new / existing), import, rke2)
- Auth providers (shortest form is keycloak saml)
- Create/Edit resource types - project, namespace, some random others
- Create/Edit resource types that have sub-types (secrets, workloads, OPA Gateway / Constraints)

Also Addressed
- Only show top border of the footer if CruResource is in edit view
- Fixed hide of errors

* Fix conditional showing of Group `Assign To` and `Refresh` buttons

Buttons should only show if
- There is a non-local auth provider enabled
- The user has the correct permissions for the relevent action

Fixes #4897

* Fix live expiry badge used on api keys table

* Hide local cluster in LandingPagePreference and api key (token) list

* HARVESTER: Fix Grafana metrics reload failure

* Address some TODOs

* minor fixes

* Rename i18n to l10n, make use of default folders

* Improve adding multiple routes

* Config map view inconsistent for binary data (#5602)

* Fix Config map view inconsistent for binary data

Co-authored-by: Alexandre Alves <aalves@AlexandresMBP2.lan>
Co-authored-by: Alexandre Alves <aalves@Alexandres-MacBook-Pro-2.local>

* check for existance of limit prop so that deleting a project doesnt silently fail (#5661)

Co-authored-by: Alexandre Alves <aalves@AlexandresMBP2.lan>

* Changes following review

* Fix CruResource `Edit as Yaml` feature
- ensure sticky buttons are stuck at bottom
- doesn't apply sticky buttons to direct `Edit as Yaml` feature outside of CruResource

* Fix navigation & ns filter bugs
- nav from explorer to epinio cluster (errors in console)
- nav from epinio cluster to explorer (ns filter broken)

* Remove epinio ns and config validation
- this was the process that will be replaced with Sean's work
- it's suffered bit rot and was broken after the merge
  - not running in places
  - it visually broke the labelled select (in multiple places)

* remaining todos

* fixes following validation change

* fix bad yarn lock file

Co-authored-by: Neil MacDougall <nmacdougall@suse.com>
Co-authored-by: Neil MacDougall <nwmac@users.noreply.github.com>
Co-authored-by: Neill Somerville <neill.somerville@gmail.com>
Co-authored-by: Phillip Rak <rak.phillip@gmail.com>
Co-authored-by: Nancy Butler <42977925+mantis-toboggan-md@users.noreply.github.com>
Co-authored-by: Shavin Fonseka <Shavindra@users.noreply.github.com>
Co-authored-by: Alexandre Alves <97888974+aalves08@users.noreply.github.com>
Co-authored-by: Alexandre Alves <aalves@AlexandresMBP2.lan>
Co-authored-by: Catherine Luse <catherine.luse@gmail.com>
Co-authored-by: Giuseppe Leo <giuseppe.leo@suse.com>
Co-authored-by: cnotv <giuseppe.leo@suse.de>
Co-authored-by: Alexandre Alves <aalves@Alexandres-MacBook-Pro-2.local>
Co-authored-by: n313893254 <n313893254@126.com>
# Conflicts:
#	shell/assets/translations/en-us.yaml
#	shell/components/CruResource.vue
#	shell/components/ResourceDetail/Masthead.vue
#	shell/components/ResourceList/Masthead.vue
#	shell/components/SortableTable/index.vue
#	shell/components/form/KeyValue.vue
#	shell/components/form/LabeledInput.vue
#	shell/components/form/NameNsDescription.vue
#	shell/components/form/NotificationSettings.vue
#	shell/components/formatter/PodsUsage.vue
#	shell/components/nav/Header.vue
#	shell/config/product/neuvector.js
#	shell/detail/workload/index.vue
#	shell/edit/provisioning.cattle.io.cluster/CustomCommand.vue
#	shell/models/cluster.x-k8s.io.machinedeployment.js
#	shell/models/harvester/kubevirt.io.virtualmachineinstance.js
#	shell/models/workload.js
#	shell/pages/c/_cluster/settings/banners.vue
#	shell/plugins/steve/actions.js
#	shell/store/type-map.js
This commit is contained in:
Richard Cox 2022-05-06 16:10:37 +01:00
parent 25acc1f8eb
commit 50aed3eb9e
155 changed files with 7686 additions and 1655 deletions

View File

@ -133,8 +133,8 @@ export default function($plugin) {
Next, create a new file `pkg/testplugin/product.js` with this content:
```
export function init($plugin, pluginName) {
const { product } = $plugin.DSL(pluginName);
export function init($plugin, store) {
const { product } = $plugin.DSL(store, $plugin.name);
product({
icon: 'gear',
@ -146,7 +146,7 @@ export function init($plugin, pluginName) {
```
You should now be able to run the UI again wtih:
You should now be able to run the UI again with:
```
yarn dev
@ -160,7 +160,7 @@ The developer experience is still the same - you can edit the code in `pkg/testp
## Use Case: Dynamically loading a UI Plugin
In the previous use case, the UI package we created was statically built into the UI - this works great for the developer use case where we want to be able to iterate, make changes and see those via hot-reload as we do with Rancher Dashbaord today.
In the previous use case, the UI package we created was statically built into the UI - this works great for the developer use case where we want to be able to iterate, make changes and see those via hot-reload as we do with Rancher Dashboard today.
This use case illustrates being able to build a UI plugin as a package and then be able to load that into the UI, dynamically at run-time.

View File

@ -58,7 +58,7 @@ The dashboard will proxy requests to the API, so the interfaces are available vi
The high-level way the entire UI works is that API calls are made to load data from the server, and then a "watch" is started to notify us of changes so that information can be kept up to date at all times without polling or refreshing. You can load a single resource by ID, an entire collection of all those resources, or something in between, and they should still stay up to date. This works by having an array of a single authoritative copy of all the "known" models saved in the API stores (`management` & `cluster`) and updating the data when an event is received from the "subscribe" websocket. The update is done on the _existing_ copy, so that anything that refers to it finds out that it changed through Vue's reactivity. When manipulating models or collections of results from the API, some care is needed to make sure you are keeping that single copy and not making extras or turning a "live" array of models into a "dead" clone of it.
The most basic operations are `find({type, id})` to load a single resource by ID, `findAll({type})` load all of them. These (anything starting with `find`) are async calls to the API. Getters like `all(type)` and `byId(type, id)` are synchronous and return only info that has already been previously loaded. See `plugins/steve/` for all the available actions and getters.
The most basic operations are `find({type, id})` to load a single resource by ID, `findAll({type})` load all of them. These (anything starting with `find`) are async calls to the API. Getters like `all(type)` and `byId(type, id)` are synchronous and return only info that has already been previously loaded. See `plugins/dashboard-store/` for all the available actions and getters.
## Resources
@ -151,13 +151,13 @@ return hasAccess ? this.$store.dispatch('cluster/findAll', { type }) : Promise.r
The ES6 class models in the `models` directory are used to represent Kubernetes resources. The class applies properties and methods to the resource, which defines how the resource can function in the UI and what other components can do with it. Different APIs return models in different structures, but the implementation of the models allows some common functionality to be available for any of them, such as `someModel.name`, `someModel.description`, `setLabels` or `setAnnotations`.
Much of the reused functionality for each model is taken from the Steve plugin. The class-based models use functionality from `plugins/steve/resource-class.js`.
Much of the reused functionality for each model is taken from the Steve plugin. The class-based models use functionality from `plugins/dashboard-store/resource-class.js`.
The `Resource` class in `plugins/steve/resource-class.js` should not have any fields defined that conflict with any key ever returned by the APIs (e.g. name, description, state, etc used to be a problem). The `SteveModel` (`plugins/steve/steve-class.js`) and `NormanModel` (`plugins/steve/norman-class.js`) know how to handle those keys separately now, so the computed name/description/etc is only in the Steve implementation. It is no longer needed to use names like `_name` to avoid naming conflicts.
The `Resource` class in `plugins/dashboard-store/resource-class.js` should not have any fields defined that conflict with any key ever returned by the APIs (e.g. name, description, state, etc used to be a problem). The `SteveModel` (`plugins/steve/steve-class.js`) and `NormanModel` (`plugins/steve/norman-class.js`) know how to handle those keys separately now, so the computed name/description/etc is only in the Steve implementation. It is no longer needed to use names like `_name` to avoid naming conflicts.
### Extending Models
The `Resource` class in `plugins/steve/resource-class.js` is the base class for everything and should not be directly extended. (There is a proxy-based counterpart of `Resource` which is the default export from `plugins/steve/resource-instance.js` as well.) If a model needs to extend the basic functionality of a resource, it should extend one of these three models:
The `Resource` class in `plugins/dashboard-store/resource-class.js` is the base class for everything and should not be directly extended. (There is a proxy-based counterpart of `Resource` which is the default export from `plugins/dashboard-store/resource-instance.js` as well.) If a model needs to extend the basic functionality of a resource, it should extend one of these three models:
- `NormanModel`: For a Rancher management type being loaded via the Norman API (/v3, the Rancher store). These have names, descriptions and labels at the root of the object.
- `HybridModel`: This model is used for old Rancher types, such as a Project (mostly in management.cattle.io), that are loaded with the Steve API (/v1, the cluster/management stores). These have the name and description at the root, but labels under metadata.
@ -169,15 +169,15 @@ The Norman and Hybrid models extend the basic Resource class. The Hybrid model i
The Rancher API returns plain objects containing resource data, but we need to convert that data into classes so that we can use methods on them.
Whenever we get an object from the API, we run the `classify` function (at `plugins/steve/classify.js`) which looks at the type field and figures out what type it is supposed to be. That file gives you an instance of a model which you can use to access the properties.
Whenever we get an object from the API, we run the `classify` function (at `plugins/dashboard-store/classify.js`) which looks at the type field and figures out what type it is supposed to be. That file gives you an instance of a model which you can use to access the properties.
This 'rehydration' process is important for server-side rendering, in which the server side returns a block of JSON that needs to be converted to classes. In `plugins/steve/rehydrate-all.js`, we use `this.nuxt.hook` to add a hook in which nuxt looks over all the objects. It recurses over the object from nuxt, which is the data that you get back from the server when server-side rendering mode is turned on, and converts all of the objects to classes. While the `rehydrate-all` code is not used in production, it may be in the future.
This 'rehydration' process is important for server-side rendering, in which the server side returns a block of JSON that needs to be converted to classes. In `plugins/dashboard-store/rehydrate-all.js`, we use `this.nuxt.hook` to add a hook in which nuxt looks over all the objects. It recurses over the object from nuxt, which is the data that you get back from the server when server-side rendering mode is turned on, and converts all of the objects to classes. While the `rehydrate-all` code is not used in production, it may be in the future.
We also 'dehydrate' resources by stripping out properties with double underscores before sending data to the Rancher API. We remove these properties because they are only used on the client side.
## Creating and Fetching Resources
Most of the options to create and fetch resources can be achieved via dispatching actions defined in `/plugins/steve/actions.js`
Most of the options to create and fetch resources can be achieved via dispatching actions defined in `/plugins/dashboard-store/actions.js`
| Function | Action | Example Command | Description |
|-------------|--------|-----------------|-----|
@ -191,7 +191,7 @@ Once objects of most types are fetched they will be automatically updated. See [
## Synchronous Fetching
It's possible to retrieve values from the store synchronously via `getters`. For resources this is not normally advised (they may not yet have been fetched), however for items such as schemas, it is valid. Some of the core getters are defined in `/plugins/steve/getters.js`:
It's possible to retrieve values from the store synchronously via `getters`. For resources this is not normally advised (they may not yet have been fetched), however for items such as schemas, it is valid. Some of the core getters are defined in `/plugins/dashboard-store/getters.js`:
```ts
$store.getters['<store type>/byId'](<resource type>, <id>])

View File

@ -16,7 +16,7 @@ List views can become slow to load when the UI attempts to load too much informa
## Deferring Duplicate Requests
In `plugins/steve/actions.js`, if there are multiple requests for the same URL including the same path and headers, the store will recognize that a similar request already exists. Instead of making another request, it will defer it, and at the end it will only send one API call. This works for all resources in general.
In `plugins/dashboard-store/actions.js`, if there are multiple requests for the same URL including the same path and headers, the store will recognize that a similar request already exists. Instead of making another request, it will defer it, and at the end it will only send one API call. This works for all resources in general.
# Pagination

View File

@ -8,4 +8,4 @@ We have no concrete plans for this, but can envision several situations where we
To disable it for the whole server for development, add `--spa`. To disable it for a single page load, add `?spa` (or `&spa`) to the query string. It is harder, but possible, to write something that works in SSR but breaks in SPA, so these are good for debugging issues.
SSR causes certain NUXT component functions to execute server side, for example `async fetch`, `asyncData` and `nuxtServerInit`. State returned by these and the core Vuex store is transferred back to the client by the `window.__NUXT__` property. As these contain resources that should be Proxy objects the Dashboard rehydrates them as such via `plugins/steve/index.js`. There you can see any resource tagged with `__rehydrate` or array with `__rehydrateAll__<x>` will be converted into back into a Proxy object in the client.
SSR causes certain NUXT component functions to execute server side, for example `async fetch`, `asyncData` and `nuxtServerInit`. State returned by these and the core Vuex store is transferred back to the client by the `window.__NUXT__` property. As these contain resources that should be Proxy objects the Dashboard rehydrates them as such via `plugins/dashboard-store/index.js`. There you can see any resource tagged with `__rehydrate` or array with `__rehydrateAll__<x>` will be converted into back into a Proxy object in the client.

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 199.412384 165" style="enable-background:new 0 0 199.412384 165;" xml:space="preserve">
<style type="text/css">
.st1{fill:#FFFFFF;}
.st4{fill:#004D93;}
</style>
<path class="st4" d="M180,165H20c-11,0-20-9-20-20V20C0,9,9,0,20,0h160c11,0,20,9,20,20v125C200,156,191,165,180,165z"/>
<g>
<path class="st1" d="M47.4770508,106.6396484V79.3925781H138.4375c-2.987793,5.4580078-4.6884766,11.7177734-4.6884766,18.3662109
v13.2089844c3.2719727-0.9838867,6.123291-2.234375,8.0161133-3.1591797l6.5839844-3.2177734l5.3999023,2.637207v-9.4692383
c0-10.1269531,8.2392578-18.3662109,18.3662109-18.3662109v-20h-70.4760742V47.4648438h10v-20h-10H37.6933594H27.4770508v20
h10.2163086v11.9277344H27.4770508v53.1425781c6.8833008,0,13.9960938-2.9619141,17.6079102-4.7265625L47.4770508,106.6396484z
M57.6933594,47.4648438h23.9458008v11.9277344H57.6933594V47.4648438z"/>
<path class="st1" d="M152.7397461,112.2998047l-4.3901367-2.1445312l-4.3891602,2.1455078
c-4.0004883,1.9550781-11.8891602,5.234375-19.7788086,5.234375c-7.8886719,0-15.7788086-3.2792969-19.7797852-5.234375
l-4.390625-2.1455078l-4.3901367,2.1455078c-4.0004883,1.9550781-11.8886719,5.234375-19.7773438,5.234375
c-7.8862305,0-15.7802734-3.2802734-19.7836914-5.2353516l-4.3901367-2.1445312l-4.3891602,2.1455078
c-4.0004883,1.9550781-11.9145508,5.234375-19.8037109,5.234375v20c9.0991211,0,17.6704102-2.5546875,24.1953125-5.2783203
c6.527832,2.7236328,15.074707,5.2783203,24.1713867,5.2783203c9.0986328,0,17.6435547-2.5537109,24.168457-5.2773438
c6.5253906,2.7236328,15.0712891,5.2773438,24.1694336,5.2773438c9.0996094,0,17.6455078-2.5546875,24.1704102-5.2783203
c6.527832,2.7236328,15.074707,5.2783203,24.1708984,5.2783203v-20
C164.637207,117.5351562,156.7431641,114.2548828,152.7397461,112.2998047z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1 @@
module.exports = require('./.shell/pkg/babel.config.js');

View File

@ -0,0 +1,88 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../../models/applications';
import { EPINIO_TYPES } from '../../types';
import { sortBy } from '@shell/utils/sort';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import EpinioConfiguration from '../../models/configurations';
interface Data {
values: string[]
}
export default Vue.extend<Data, any, any, any>({
components: { LabeledSelect },
props: {
application: {
type: Object as PropType<Application>,
required: true
},
mode: {
type: String,
required: true
},
},
async fetch() {
await this.$store.dispatch('epinio/findAll', { type: EPINIO_TYPES.CONFIGURATION });
},
data() {
return { values: this.application.configuration.configurations };
},
computed: {
configurations() {
const list = this.$store.getters['epinio/all'](EPINIO_TYPES.CONFIGURATION)
.filter((s: EpinioConfiguration) => s.metadata.namespace === this.application.metadata.namespace)
.map((s: EpinioConfiguration) => ({
label: s.metadata.name,
value: s.metadata.name,
}));
return sortBy(list, 'label');
},
noConfigs() {
return !this.$fetchState.pending && !this.configurations.length;
}
},
watch: {
values() {
this.$emit('change', this.values);
},
noConfigs(neu) {
if (neu && this.values?.length) {
// Selected configurations are no longer valid
this.values = [];
}
}
},
});
</script>
<template>
<div class="col span-6">
<LabeledSelect
v-model="values"
:loading="$fetchState.pending"
:disabled="noConfigs"
:options="configurations"
:searchable="true"
:mode="mode"
:multiple="true"
:label="t('typeLabel.configurations', { count: 2})"
:placeholder="noConfigs ? t('epinio.applications.steps.configurations.select.placeholderNoOptions') : t('epinio.applications.steps.configurations.select.placeholderWithOptions')"
/>
</div>
</template>
<style lang='scss' scoped>
.labeled-select {
min-height: 79px;
}
</style>

View File

@ -0,0 +1,158 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../../models/applications';
import NameNsDescription from '@shell/components/form/NameNsDescription.vue';
import LabeledInput from '@shell/components/form/LabeledInput.vue';
import KeyValue from '@shell/components/form/KeyValue.vue';
import ArrayList from '@shell/components/form/ArrayList.vue';
import { EPINIO_TYPES } from '../../types';
import { sortBy } from '@shell/utils/sort';
interface Data {
errors: string[],
values: {
meta: {
name: string,
namespace: string
},
configuration: {
instances: number,
environment: {}
}
}
}
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
components: {
ArrayList,
NameNsDescription,
LabeledInput,
KeyValue
},
props: {
application: {
type: Object as PropType<Application>,
required: true
},
mode: {
type: String,
required: true
},
},
data() {
return {
errors: [],
values: {
meta: {
name: this.application.meta?.name,
namespace: this.application.meta?.namespace
},
configuration: {
instances: this.application.configuration?.instances || 1,
environment: this.application.configuration?.environment || {},
routes: this.application.configuration?.routes || [],
},
}
};
},
mounted() {
this.$emit('valid', this.valid);
},
watch: {
'values.configuration.instances'() {
this.update();
},
'values.configuration.environment'() {
this.update();
},
'values.configuration.routes'() {
this.update();
},
valid() {
this.$emit('valid', this.valid);
}
},
computed: {
namespaces() {
return sortBy(this.$store.getters['epinio/all'](EPINIO_TYPES.NAMESPACE), 'name');
},
valid() {
const validName = !!this.values.meta?.name;
const validNamespace = !!this.values.meta?.namespace;
const validInstances = typeof this.values.configuration?.instances !== 'string' && this.values.configuration?.instances >= 0;
return validName && validNamespace && validInstances;
}
},
methods: {
update() {
this.$emit('change', {
meta: this.values.meta,
configuration: this.values.configuration,
});
},
},
});
</script>
<template>
<div>
<div class="col">
<NameNsDescription
name-key="name"
namespace-key="namespace"
:namespaces-override="namespaces"
:description-hidden="true"
:value="values.meta"
:mode="mode"
@change="update"
/>
</div>
<div class="col span-6">
<LabeledInput
v-model.number="values.configuration.instances"
type="number"
min="0"
required
:mode="mode"
:label="t('epinio.applications.create.instances')"
/>
</div>
<div class="spacer"></div>
<div class="col span-8">
<ArrayList
v-model="values.configuration.routes"
:title="t('epinio.applications.create.routes.title')"
:protip="t('epinio.applications.create.routes.tooltip')"
:mode="mode"
:value-placeholder="t('epinio.applications.create.routes.placeholder')"
/>
</div>
<div class="spacer"></div>
<div class="col span-8">
<KeyValue
v-model="values.configuration.environment"
:mode="mode"
:title="t('epinio.applications.create.envvar.title')"
:key-label="t('epinio.applications.create.envvar.keyLabel')"
:value-label="t('epinio.applications.create.envvar.valueLabel')"
:parse-lines-from-file="true"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,223 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../../models/applications';
import ApplicationAction, { APPLICATION_ACTION_TYPE } from '../../models/application-action';
import SortableTable from '@shell/components/SortableTable/index.vue';
import Checkbox from '@shell/components/form/Checkbox.vue';
import BadgeState from '@shell/components/BadgeState.vue';
import { STATE, DESCRIPTION } from '@shell/config/table-headers';
import { EPINIO_TYPES, APPLICATION_ACTION_STATE, APPLICATION_SOURCE_TYPE } from '../../types';
import { EpinioAppSource } from '../../components/application/AppSource.vue';
interface Data {
running: boolean;
actionHeaders: any[];
actions: ApplicationAction[]
}
export default Vue.extend<Data, any, any, any>({
components: {
SortableTable,
BadgeState,
Checkbox,
},
props: {
application: {
type: Object as PropType<Application>,
required: true
},
source: {
type: Object as PropType<EpinioAppSource>,
required: true
},
mode: {
type: String,
required: true
},
step: {
type: Object as PropType<any>,
required: true
}
},
async fetch() {
const coreArgs = {
application: this.application,
type: EPINIO_TYPES.APP_ACTION,
};
this.actions.push(await this.$store.dispatch('epinio/create', {
action: APPLICATION_ACTION_TYPE.CREATE,
index: 0, // index used for sorting
...coreArgs,
}));
if (this.source.type === APPLICATION_SOURCE_TYPE.ARCHIVE ||
this.source.type === APPLICATION_SOURCE_TYPE.FOLDER) {
this.actions.push(await this.$store.dispatch('epinio/create', {
action: APPLICATION_ACTION_TYPE.UPLOAD,
index: 1,
...coreArgs,
}));
}
if (this.source.type === APPLICATION_SOURCE_TYPE.GIT_URL) {
this.actions.push(await this.$store.dispatch('epinio/create', {
action: APPLICATION_ACTION_TYPE.GIT_FETCH,
index: 1,
...coreArgs,
}));
}
if (this.source.type === APPLICATION_SOURCE_TYPE.ARCHIVE ||
this.source.type === APPLICATION_SOURCE_TYPE.FOLDER ||
this.source.type === APPLICATION_SOURCE_TYPE.GIT_URL) {
this.actions.push(await this.$store.dispatch('epinio/create', {
action: APPLICATION_ACTION_TYPE.BUILD,
index: 2,
...coreArgs,
}));
}
this.actions.push(await this.$store.dispatch('epinio/create', {
action: APPLICATION_ACTION_TYPE.DEPLOY,
index: 3,
...coreArgs,
}));
this.create();
},
data() {
return {
running: false,
actionHeaders: [
{
name: 'epinio-name',
labelKey: 'epinio.applications.steps.progress.table.stage.label',
value: 'name',
sort: ['index'],
width: 100,
},
{
...DESCRIPTION,
sort: undefined,
value: 'description',
width: 450,
},
{
...STATE,
sort: undefined,
labelKey: 'epinio.applications.steps.progress.table.status',
width: 150
},
],
actions: [],
APPLICATION_ACTION_STATE
};
},
computed: {
actionsToRun() {
return this.actions.filter((action: ApplicationAction) => action.run);
}
},
watch: {
running(neu, prev) {
if (prev && !neu) {
Vue.set(this.step, 'ready', true);
}
}
},
methods: {
async fetchApp() {
try {
await this.application.forceFetch();
} catch (err) {
}
},
async create() {
Vue.set(this, 'running', true);
const enabledActions = [...this.actionsToRun];
for (const action of enabledActions) {
try {
await action.execute({ source: this.source });
} catch (err) {
Vue.set(this, 'running', false);
console.error(err);// eslint-disable-line no-console
await this.fetchApp();
return;
}
}
await this.fetchApp();
Vue.set(this, 'running', false);
this.$emit('finished', true);
}
}
});
</script>
<template>
<div v-if="!$fetchState.pending" class="progress-container">
<div class="progress">
<SortableTable
:rows="actions"
:headers="actionHeaders"
:table-actions="false"
:row-actions="false"
default-sort-by="epinio-name"
:search="false"
key-field="key"
>
<template #cell:index="{row}">
<Checkbox v-model="row.run" :disabled="true" />
</template>
<template #cell:state="{row}">
<div class="status">
<i
v-if="row.state === APPLICATION_ACTION_STATE.RUNNING"
v-tooltip="row.stateDisplay"
class="icon icon-lg icon-spinner icon-spin"
/>
<BadgeState v-else :color="row.stateBackground" :label="row.stateDisplay" class="badge" />
</div>
</template>
</SortableTable>
</div>
</div>
</template>
<style lang="scss" scoped>
.progress-container {
display: flex;
justify-content: center;
.progress {
padding: 10px 0;
$statusHeight: 20px;
.status {
min-height: $statusHeight; // Ensure switching from spinner to badge doesn't wibble
display: flex;
align-items: center;
.badge {
min-height: $statusHeight;
}
}
}
}
</style>

View File

@ -0,0 +1,351 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../../models/applications';
import LabeledInput from '@shell/components/form/LabeledInput.vue';
import LabeledSelect from '@shell/components/form/LabeledSelect.vue';
import FileSelector from '@shell/components/form/FileSelector.vue';
import RadioGroup from '@shell/components/form/RadioGroup.vue';
import { APPLICATION_SOURCE_TYPE } from '../../types';
import { generateZip } from '@shell/utils/download';
interface Archive{
tarball: string,
fileName: string,
}
interface Container {
url: string,
}
interface GitUrl {
url: string,
branch: string
}
interface BuilderImage {
value: string,
default: boolean,
}
interface Data {
archive: Archive,
container: Container,
gitUrl: GitUrl,
builderImage: BuilderImage,
types: any[],
type: string, // APPLICATION_SOURCE_TYPE,
APPLICATION_SOURCE_TYPE: typeof APPLICATION_SOURCE_TYPE
}
export interface EpinioAppSource {
type: string // APPLICATION_SOURCE_TYPE,
archive: Archive,
container: Container,
gitUrl: GitUrl,
builderImage: BuilderImage,
}
const DEFAULT_BUILD_PACK = 'paketobuildpacks/builder:full';
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
components: {
FileSelector,
LabeledInput,
LabeledSelect,
RadioGroup
},
props: {
application: {
type: Object as PropType<Application>,
required: true
},
source: {
type: Object as PropType<EpinioAppSource>,
default: null
},
mode: {
type: String,
required: true
},
},
data() {
return {
archive: {
tarball: this.source?.archive.tarball || '',
fileName: this.source?.archive.fileName || '',
},
container: { url: this.source?.container.url },
gitUrl: {
url: this.source?.gitUrl.url || '',
branch: this.source?.gitUrl.branch || '',
},
builderImage: {
value: this.source?.builderImage?.value || DEFAULT_BUILD_PACK,
default: this.source?.builderImage?.default !== undefined ? this.source.builderImage.default : true,
},
types: [{
label: this.t('epinio.applications.steps.source.folder.label'),
value: APPLICATION_SOURCE_TYPE.FOLDER
}, {
label: this.t('epinio.applications.steps.source.archive.label'),
value: APPLICATION_SOURCE_TYPE.ARCHIVE
}, {
label: this.t('epinio.applications.steps.source.containerUrl.label'),
value: APPLICATION_SOURCE_TYPE.CONTAINER_URL
}, {
label: this.t('epinio.applications.steps.source.gitUrl.label'),
value: APPLICATION_SOURCE_TYPE.GIT_URL
}],
type: this.source?.type || APPLICATION_SOURCE_TYPE.FOLDER,
APPLICATION_SOURCE_TYPE
};
},
mounted() {
this.update();
},
methods: {
onFileSelected(file: File) {
this.archive.tarball = file;
this.archive.fileName = file.name;
this.update();
},
onFolderSelected(files: any[]) {
let folderName: string = '';
// Determine parent folder name
for (const f of files) {
const paths = f.webkitRelativePath.split('/');
if (paths.length > 1) {
if (!folderName) {
folderName = paths[0];
continue;
}
if (folderName !== paths[0]) {
folderName = '';
break;
}
}
}
const filesToZip = files.reduce((res, f) => {
let path = f.webkitRelativePath;
if (folderName) {
// Remove parent folder name
const parts = path.split('/');
parts.shift();
path = parts.join('/');
}
res[path] = f;
return res;
}, {} as { [key: string]: any});
generateZip(filesToZip).then((zip) => {
Vue.set(this.archive, 'tarball', zip);
Vue.set(this.archive, 'fileName', folderName || 'folder');
this.update();
// downloadFile('resources.zip', zip, 'application/zip');
});
},
update() {
this.$emit('change', {
type: this.type,
archive: this.archive,
container: this.container,
gitUrl: this.gitUrl,
builderImage: this.builderImage
});
},
onImageType(defaultImage: boolean) {
if (defaultImage) {
this.builderImage.value = DEFAULT_BUILD_PACK;
}
this.builderImage.default = defaultImage;
this.update();
}
},
watch: {
type() {
this.update();
},
valid() {
this.$emit('valid', this.valid);
}
},
computed: {
valid() {
switch (this.type) {
case APPLICATION_SOURCE_TYPE.ARCHIVE:
case APPLICATION_SOURCE_TYPE.FOLDER:
return !!this.archive.tarball && !!this.builderImage.value;
case APPLICATION_SOURCE_TYPE.CONTAINER_URL:
return !!this.container.url;
case APPLICATION_SOURCE_TYPE.GIT_URL:
return !!this.gitUrl.url && !!this.gitUrl.branch && !!this.builderImage.value;
}
return false;
},
showBuilderImage() {
return [
APPLICATION_SOURCE_TYPE.ARCHIVE,
APPLICATION_SOURCE_TYPE.FOLDER,
APPLICATION_SOURCE_TYPE.GIT_URL,
].includes(this.type);
}
}
});
</script>
<template>
<div class="appSource">
<LabeledSelect
v-model="type"
label="Source Type"
:options="types"
:mode="mode"
:clearable="false"
:reduce="(e) => e.value"
/>
<template v-if="type === APPLICATION_SOURCE_TYPE.ARCHIVE">
<div class="spacer archive">
<h3>{{ t('epinio.applications.steps.source.archive.file.label') }}</h3>
<LabeledInput
v-model="archive.fileName"
:disabled="true"
:tooltip="t('epinio.applications.steps.source.archive.file.tooltip')"
:label="t('epinio.applications.steps.source.archive.file.inputLabel')"
:required="true"
/>
<FileSelector
class="role-tertiary add mt-5"
:label="t('generic.readFromFile')"
:mode="mode"
:raw-data="true"
@selected="onFileSelected"
/>
</div>
</template>
<template v-else-if="type === APPLICATION_SOURCE_TYPE.FOLDER">
<div class="spacer archive">
<h3>{{ t('epinio.applications.steps.source.folder.file.label') }}</h3>
<LabeledInput
v-model="archive.fileName"
:disabled="true"
:tooltip="t('epinio.applications.steps.source.folder.file.tooltip')"
:label="t('epinio.applications.steps.source.folder.file.inputLabel')"
:required="true"
/>
<FileSelector
class="role-tertiary add mt-5"
:label="t('generic.readFromFolder')"
:mode="mode"
:raw-data="true"
:directory="true"
:multiple="true"
@selected="onFolderSelected"
/>
</div>
</template>
<template v-else-if="type === APPLICATION_SOURCE_TYPE.CONTAINER_URL">
<div class="spacer archive">
<h3>{{ t('epinio.applications.steps.source.containerUrl.url.label') }}</h3>
<LabeledInput
v-model="container.url"
:tooltip="t('epinio.applications.steps.source.containerUrl.url.tooltip')"
:label="t('epinio.applications.steps.source.containerUrl.url.inputLabel')"
:required="true"
@input="update"
/>
</div>
</template>
<template v-else-if="type === APPLICATION_SOURCE_TYPE.GIT_URL">
<div class="spacer archive">
<h3>{{ t('epinio.applications.steps.source.gitUrl.url.label') }}</h3>
<LabeledInput
v-model="gitUrl.url"
:tooltip="t('epinio.applications.steps.source.gitUrl.url.tooltip')"
:label="t('epinio.applications.steps.source.gitUrl.url.inputLabel')"
:required="true"
@input="update"
/>
</div>
<div class="spacer archive">
<h3>{{ t('epinio.applications.steps.source.gitUrl.branch.label') }}</h3>
<LabeledInput
v-model="gitUrl.branch"
:tooltip="t('epinio.applications.steps.source.gitUrl.branch.tooltip')"
:label="t('epinio.applications.steps.source.gitUrl.branch.inputLabel')"
:required="true"
@input="update"
/>
</div>
<!-- <br><br>
Debug<br>
Mode: {{ mode }}<br>
Value: {{ JSON.stringify(value) }}<br>
initialModel: {{ JSON.stringify(initialModel) }}<br> -->
</template>
<template v-if="showBuilderImage">
<div class="spacer">
<RadioGroup
name="defaultBuilderImage"
:value="builderImage.default"
:labels="[t('epinio.applications.steps.source.archive.builderimage.default'), t('epinio.applications.steps.source.archive.builderimage.custom')]"
:options="[true, false]"
:label-key="'epinio.applications.steps.source.archive.builderimage.label'"
@input="onImageType"
/>
<LabeledInput
v-model="builderImage.value"
:disabled="builderImage.default"
:tooltip="t('epinio.applications.steps.source.archive.builderimage.tooltip')"
:mode="mode"
@input="update"
/>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.appSource {
max-width: 500px;
}
.archive {
display: flex;
flex-direction: column;
.file-selector {
align-self: end;
}
}
</style>

253
pkg/epinio/config/epinio.ts Normal file
View File

@ -0,0 +1,253 @@
import {
AGE, NAME, RAM, SIMPLE_NAME, STATE
} from '@shell/config/table-headers';
import { createEpinioRoute, rootEpinioRoute } from '../utils/custom-routing';
import { EPINIO_PRODUCT_NAME, EPINIO_STANDALONE_CLUSTER_NAME, EPINIO_TYPES } from '../types';
import EpinioDiscovery from '../utils/epinio-discovery';
import { MULTI_CLUSTER } from '@shell/store/features';
export function init($plugin: any, store: any) {
const {
product,
basicType,
headers,
configureType,
spoofedType,
weightType
} = $plugin.DSL(store, $plugin.name);
const isEpinioSingleProduct = process.env.rancherEnv === 'epinio';
if (isEpinioSingleProduct) {
store.dispatch('setIsSingleProduct', {
logo: require(`../assets/logo-epinio.svg`),
productNameKey: 'epinio.label',
afterLoginRoute: createEpinioRoute('c-cluster-applications', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
logoRoute: createEpinioRoute('c-cluster-applications', { cluster: EPINIO_STANDALONE_CLUSTER_NAME }),
disableSteveSockets: true,
});
}
product({
// ifHaveType: CAPI.RANCHER_CLUSTER,
ifFeature: MULTI_CLUSTER,
category: EPINIO_PRODUCT_NAME,
isMultiClusterApp: true,
inStore: EPINIO_PRODUCT_NAME,
icon: 'epinio',
iconHeader: isEpinioSingleProduct ? undefined : require(`../assets/logo-epinio.svg`),
removable: false,
showClusterSwitcher: false,
to: rootEpinioRoute(),
showNamespaceFilter: true,
customNamespaceFilter: true,
});
// Internal Types
spoofedType({
label: store.getters['type-map/labelFor']({ id: EPINIO_TYPES.INSTANCE }, 2),
type: EPINIO_TYPES.INSTANCE,
product: EPINIO_PRODUCT_NAME,
collectionMethods: [],
schemas: [
{
id: EPINIO_TYPES.INSTANCE,
type: 'schema',
collectionMethods: [],
resourceFields: {},
}
],
getInstances: async() => await EpinioDiscovery.discover(store),
});
configureType(EPINIO_TYPES.INSTANCE, {
isCreatable: false,
isEditable: false,
isRemovable: false,
showState: false,
showAge: false,
canYaml: false,
});
configureType(EPINIO_TYPES.INSTANCE, { customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.INSTANCE }) });
// App resource
weightType(EPINIO_TYPES.APP, 300, true);
configureType(EPINIO_TYPES.APP, {
isCreatable: true,
isEditable: true,
isRemovable: true,
showState: true,
showAge: false,
canYaml: false,
customRoute: createEpinioRoute('c-cluster-applications', { }),
});
// Configuration resource
weightType(EPINIO_TYPES.CONFIGURATION, 200, true);
configureType(EPINIO_TYPES.CONFIGURATION, {
isCreatable: true,
isEditable: true,
isRemovable: true,
showState: false,
showAge: false,
canYaml: false,
customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.CONFIGURATION }),
});
// Namespace resource
weightType(EPINIO_TYPES.NAMESPACE, 100, true);
configureType(EPINIO_TYPES.NAMESPACE, {
isCreatable: true,
isEditable: true,
isRemovable: true,
showState: false,
showAge: false,
canYaml: false,
customRoute: createEpinioRoute('c-cluster-resource', { resource: EPINIO_TYPES.NAMESPACE }),
showListMasthead: false // Disable default masthead because we provide a custom one.
});
basicType([
EPINIO_TYPES.APP,
EPINIO_TYPES.NAMESPACE,
EPINIO_TYPES.CONFIGURATION
]);
headers(EPINIO_TYPES.APP, [
STATE,
NAME,
{
name: 'namespace',
labelKey: 'epinio.tableHeaders.namespace',
value: 'meta.namespace',
sort: ['meta.namespace'],
formatter: 'LinkDetail',
formatterOpts: { reference: 'nsLocation' }
},
{
name: 'dep-status',
labelKey: 'tableHeaders.status',
value: 'deployment.status',
sort: ['deployment.status'],
},
{
name: 'route',
labelKey: 'epinio.applications.tableHeaders.route',
value: 'configuration.route',
search: ['configuration.route'],
},
{
name: 'configurations',
labelKey: 'epinio.applications.tableHeaders.boundConfigs',
search: ['configuration.configurations'],
},
{
name: 'deployedBy',
labelKey: 'epinio.applications.tableHeaders.deployedBy',
value: 'deployment.username',
sort: ['deployment.username'],
}
]);
const { width, canBeVariable, ...instanceName } = SIMPLE_NAME;
headers(EPINIO_TYPES.APP_INSTANCE, [
STATE,
instanceName,
{
name: 'millicpus',
label: 'Milli CPUs',
value: 'millicpus',
sort: ['millicpus'],
search: false,
},
{
...RAM,
sort: ['memoryBytes'],
search: false,
value: 'memoryBytes',
formatter: 'Si',
},
{
name: 'restarts',
label: 'Restarts',
value: 'restarts',
sort: ['restarts'],
},
{
...AGE,
value: 'createdAt',
sort: 'createdAt:desc',
}
]);
headers(EPINIO_TYPES.NAMESPACE, [
SIMPLE_NAME,
{
name: 'applications',
labelKey: 'epinio.namespace.tableHeaders.appCount',
value: 'appCount',
sort: ['appCount'],
},
{
name: 'configurations',
labelKey: 'epinio.namespace.tableHeaders.configCount',
value: 'configCount',
sort: ['configCount'],
},
]);
headers(EPINIO_TYPES.INSTANCE, [
STATE,
{
name: 'name',
labelKey: 'tableHeaders.simpleName',
sort: ['name'],
},
{
name: 'version',
labelKey: 'epinio.instances.tableHeaders.version',
sort: ['version'],
value: 'version'
},
{
name: 'api',
labelKey: 'epinio.instances.tableHeaders.api',
sort: ['api'],
},
{
name: 'rancherCluster',
labelKey: 'epinio.instances.tableHeaders.cluster',
sort: ['mgmtCluster.nameDisplay'],
value: 'mgmtCluster.nameDisplay'
},
]);
headers(EPINIO_TYPES.CONFIGURATION, [
NAME,
{
name: 'namespace',
labelKey: 'epinio.tableHeaders.namespace',
value: 'meta.namespace',
sort: ['meta.namespace'],
formatter: 'LinkDetail',
formatterOpts: { reference: 'nsLocation' }
},
{
name: 'boundApps',
labelKey: 'epinio.configurations.tableHeaders.boundApps',
search: ['configuration.boundapps'],
},
{
name: 'count',
labelKey: 'epinio.configurations.tableHeaders.variableCount',
value: 'variableCount',
sort: ['variableCount'],
},
{
name: 'createdBy',
labelKey: 'epinio.configurations.tableHeaders.createBy',
value: 'configuration.user',
sort: ['configuration.user'],
},
]);
}

View File

@ -0,0 +1,250 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../models/applications';
import SimpleBox from '@shell/components/SimpleBox.vue';
import ConsumptionGauge from '@shell/components/ConsumptionGauge.vue';
import { EPINIO_PRODUCT_NAME, EPINIO_TYPES } from '../types';
import ResourceTable from '@shell/components/ResourceTable.vue';
import Tabbed from '@shell/components/Tabbed/index.vue';
import Tab from '@shell/components/Tabbed/Tab.vue';
import PlusMinus from '@shell/components/form/PlusMinus.vue';
import { epinioExceptionToErrorsArray } from '../utils/errors';
interface Data {
}
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
components: {
SimpleBox, ConsumptionGauge, ResourceTable, Tabbed, Tab, PlusMinus
},
props: {
value: {
type: Object as PropType<Application>,
required: true
},
initialValue: {
type: Object as PropType<Application>,
required: true
},
mode: {
type: String,
required: true
},
},
data() {
return {
appInstanceSchema: this.$store.getters[`${ EPINIO_PRODUCT_NAME }/schemaFor`](EPINIO_TYPES.APP_INSTANCE),
saving: false,
};
},
methods: {
async updateInstances(newInstances: number) {
this.$set(this, 'saving', true);
try {
this.value.configuration.instances = newInstances;
await this.value.update();
await this.value.forceFetch();
} catch (err) {
console.error(`Failed to scale Application: `, epinioExceptionToErrorsArray(err)); // eslint-disable-line no-console
}
this.$set(this, 'saving', false);
}
},
computed: {}
});
</script>
<template>
<div>
<div class="simple-box-row mt-40">
<SimpleBox class="routes">
<div class="box">
<h1>{{ value.routeCount }}</h1>
<h3>
{{ t('epinio.applications.detail.counts.routes') }}
</h3>
</div>
<ul>
<li v-for="(route) in value.configuration.routes" :key="route.id">
<a v-if="value.state === 'running'" :key="route.id + 'a'" :href="`https://${route}`" target="_blank" rel="noopener noreferrer nofollow">{{ `https://${route}` }}</a>
<span v-else :key="route.id + 'a'">{{ `https://${route}` }}</span>
</li>
</ul>
</SimpleBox>
<SimpleBox class="services">
<div class="box">
<h1>{{ value.configCount }}</h1>
<h3>
{{ t('epinio.applications.detail.counts.config') }}
</h3>
</div>
</SimpleBox>
<SimpleBox class="envs">
<div class="box">
<h1>{{ value.envCount }}</h1>
<h3>
{{ t('epinio.applications.detail.counts.envVars') }}
</h3>
</div>
</SimpleBox>
</div>
<h3 v-if="value.deployment" class="mt-20">
{{ t('epinio.applications.detail.deployment.label') }}
</h3>
<Tabbed v-if="value.deployment" class="deployment" default-tab="summary">
<Tab label-key="epinio.applications.detail.deployment.summary" name="summary" :weight="1">
<div class="simple-box-row app-instances">
<SimpleBox>
<ConsumptionGauge
:resource-name="t('epinio.applications.detail.deployment.instances')"
:capacity="value.desiredInstances"
:used="value.readyInstances"
:used-as-resource-name="true"
:color-stops="{ 70: '--success', 30: '--warning', 0: '--error' }"
>
</ConsumptionGauge>
<div class="scale-instances">
<PlusMinus class="mt-15 mb-10" :value="value.desiredInstances" :disabled="saving" @minus="updateInstances(value.desiredInstances-1)" @plus="updateInstances(value.desiredInstances+1)" />
</div>
<table class="stats">
<thead>
<tr>
<th></th>
<th>Min</th>
<th>Max</th>
<th>Avg</th>
</tr>
</thead>
<tr>
<td>{{ t('tableHeaders.memory') }}</td>
<td>{{ value.instanceMemory.min }}</td>
<td>{{ value.instanceMemory.max }}</td>
<td>{{ value.instanceMemory.avg }}</td>
</tr>
<tr>
<td>{{ t('tableHeaders.cpu') }}</td>
<td>{{ value.instanceCpu.min }}</td>
<td>{{ value.instanceCpu.max }}</td>
<td>{{ value.instanceCpu.avg }}</td>
</tr>
</table>
</SimpleBox>
<SimpleBox v-if="value.sourceInfo">
<div class="deployment__origin__row">
<h4>Origin</h4><h4>
{{ value.sourceInfo.label }}
</h4>
</div>
<div v-for="d of value.sourceInfo.details" :key="d.label" class="deployment__origin__row">
<h4>{{ d.label }}</h4><h4>{{ d.value }}</h4>
</div>
</SimpleBox>
<SimpleBox v-if="value.deployment.username">
<div class="deployment__origin__row">
<h4>{{ t('epinio.applications.tableHeaders.deployedBy') }}</h4><h4>
{{ value.deployment.username }}
</h4>
</div>
</SimpleBox>
</div>
</Tab>
<Tab label-key="epinio.applications.detail.deployment.instances" name="instances">
<ResourceTable :schema="appInstanceSchema" :rows="value.instances" :table-actions="false" />
</Tab>
</Tabbed>
</div>
</template>
<style lang='scss' scoped>
.simple-box-row {
display: flex;
flex-wrap: wrap;
.simple-box {
width: 300px;
max-width: 350px;
margin-bottom: 20px;
&.routes {
width: 310px;
max-width: 360px;
}
&.services,&.envs {
width: 290px;
max-width: 340px;
}
ul {
word-break: break-all;
padding-left: 20px;
}
&:not(:last-of-type) {
margin-right: 20px;
}
.deployment__origin__row {
display: flex;
flex-direction: column;
h4:first-of-type {
font-weight: bold;
margin-bottom: 0;
}
h4:last-of-type {
word-break: break-all;
}
&:last-of-type {
h4:last-of-type {
margin-bottom: 0;
}
}
}
}
.box {
display: flex;
justify-content: space-between;
align-items: center;
& H1, H3 {
margin: 0;
}
H3 {
flex: 1;
display: flex;
justify-content: center;
}
}
}
.deployment {
max-width: 955px;
.simple-box {
margin-bottom: 0;
width: 290px;
max-width: 340px;
}
.app-instances {
tr td {
min-width:58px;
}
.scale-instances {
display: flex;
justify-content: center;
}
}
}
</style>

View File

@ -0,0 +1,111 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import Application from '../models/applications';
import CreateEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource.vue';
import ResourceTabs from '@shell/components/form/ResourceTabs/index.vue';
import Tab from '@shell/components/Tabbed/Tab.vue';
import Loading from '@shell/components/Loading.vue';
import AppInfo from '../components/application/AppInfo.vue';
import AppConfiguration from '../components/application/AppConfiguration.vue';
import { epinioExceptionToErrorsArray } from '../utils/errors';
interface Data {
}
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
data() {
return { errors: [] };
},
components: {
Loading,
CruResource,
ResourceTabs,
Tab,
AppInfo,
AppConfiguration
},
mixins: [CreateEditView],
props: {
value: {
type: Object as PropType<Application>,
required: true
},
initialValue: {
type: Object as PropType<Application>,
required: true
},
mode: {
type: String,
required: true
},
},
methods: {
async save(saveCb: (success: boolean) => void) {
this.errors = [];
try {
await this.value.update();
await this.value.forceFetch();
saveCb(true);
this.done();
} catch (err) {
this.errors = epinioExceptionToErrorsArray(err);
saveCb(false);
}
},
set(obj: { [key: string]: string}, changes: { [key: string]: string}) {
Object.entries(changes).forEach(([key, value]: [string, any]) => {
Vue.set(obj, key, value);
});
},
updateInfo(changes: any) {
this.value.meta = this.value.meta || {};
this.value.configuration = this.value.configuration || {};
this.set(this.value.meta, changes.meta);
this.set(this.value.configuration, changes.configuration);
},
updateConfigurations(changes: string[]) {
this.set(this.value.configuration, { configurations: changes });
},
}
});
</script>
<template>
<Loading v-if="!value" />
<CruResource
v-else
:can-yaml="false"
:mode="mode"
:resource="value"
:errors="errors"
@error="e=>errors = e"
@finish="save"
>
<ResourceTabs v-model="value" mode="mode">
<Tab label="Info" name="info" :weight="20">
<AppInfo
:application="value"
:mode="mode"
@change="updateInfo"
></AppInfo>
</Tab>
<Tab label="Configurations" name="configurations" :weight="10">
<AppConfiguration
:application="value"
:mode="mode"
@change="updateConfigurations"
></AppConfiguration>
</Tab>
</ResourceTabs>
</CruResource>
</template>

View File

@ -0,0 +1,128 @@
<script lang="ts">
import Vue, { PropType } from 'vue';
import CreateEditView from '@shell/mixins/create-edit-view';
import Loading from '@shell/components/Loading.vue';
import CruResource from '@shell/components/CruResource.vue';
import NameNsDescription from '@shell/components/form/NameNsDescription.vue';
import { mapGetters } from 'vuex';
import EpinioConfiguration from '../models/configurations';
import { EPINIO_TYPES } from '../types';
import KeyValue from '@shell/components/form/KeyValue.vue';
import { epinioExceptionToErrorsArray } from '../utils/errors';
import { validateKubernetesName } from '@shell/utils/validators/kubernetes-name';
interface Data {
}
export default Vue.extend<Data, any, any, any>({
components: {
Loading,
CruResource,
NameNsDescription,
KeyValue
},
mixins: [CreateEditView],
data() {
return {
errors: [],
namespaces: [],
};
},
props: {
mode: {
type: String,
required: true
},
value: {
type: Object as PropType<EpinioConfiguration>,
required: true
},
initialValue: {
type: Object as PropType<EpinioConfiguration>,
required: true
}
},
computed: {
...mapGetters({ t: 'i18n/t' }),
validationPassed() {
const nameErrors = validateKubernetesName(this.value?.metadata.name || '', this.t('epinio.namespace.name'), this.$store.getters, undefined, []);
return nameErrors.length === 0;
},
},
async fetch() {
this.namespaces = await this.$store.dispatch('epinio/findAll', { type: EPINIO_TYPES.NAMESPACE });
this.value.data = { ...this.initialValue.configuration?.details };
},
methods: {
async save(saveCb: (success: boolean) => void) {
this.errors = [];
try {
if (this.mode === 'create') {
await this.value.create();
await this.$store.dispatch('epinio/findAll', { type: this.value.type, opt: { force: true } });
}
if (this.mode === 'edit') {
await this.value.update();
await this.value.forceFetch();
}
saveCb(true);
this.done();
} catch (err) {
this.errors = epinioExceptionToErrorsArray(err);
saveCb(false);
}
},
setData(data: any[]) {
Vue.set(this.value, 'data', data);
}
}
});
</script>
<template>
<Loading v-if="!value || namespaces.length === 0" />
<CruResource
v-else-if="value && namespaces.length > 0"
:min-height="'7em'"
:mode="mode"
:done-route="doneRoute"
:resource="value"
:can-yaml="false"
:errors="errors"
:validation-passed="validationPassed"
@error="(e) => (errors = e)"
@finish="save"
@cancel="done"
>
<NameNsDescription
name-key="name"
namespace-key="namespace"
:namespaces-override="namespaces"
:description-hidden="true"
:value="value.metadata"
:mode="mode"
/>
<div class="row">
<div class="col span-11">
<KeyValue
:value="value.data"
:initial-empty-row="true"
:mode="mode"
:title="t('epinio.configurations.pairs.label')"
:title-protip="t('epinio.configurations.pairs.tooltip')"
:key-label="t('epinio.applications.create.envvar.keyLabel')"
:value-label="t('epinio.applications.create.envvar.valueLabel')"
:parse-lines-from-file="true"
@input="setData($event)"
/>
</div>
</div>
</CruResource>
</template>

39
pkg/epinio/index.ts Normal file
View File

@ -0,0 +1,39 @@
import { importTypes } from '@rancher/auto-import';
import { IPlugin, OnNavToPackage, OnNavAwayFromPackage } from '@shell/core/types';
import epinioStore from './store/epinio-store';
import epinioMgmtStore from './store/epinio-mgmt-store';
import epinioRoutes from './routing/epinio-routing';
const onEnter: OnNavToPackage = async(store, config) => {
await store.dispatch(`${ epinioMgmtStore.config.namespace }/loadManagement`);
};
const onLeave: OnNavAwayFromPackage = async(store, config) => {
// The dashboard retains the previous cluster info until another cluster is loaded, this helps when returning to the same cluster.
// We need to forget epinio cluster info
// - The polling is pretty brutal
// - The nav path through to the same epinio cluster is fraught with danger (nav from previous cluster id to blank cluster, required to switch epinio clusters)
await store.dispatch(`${ epinioStore.config.namespace }/unsubscribe`);
await store.commit(`${ epinioStore.config.namespace }/reset`);
};
// Init the package
export default function(plugin: IPlugin) {
// Auto-import model, detail, edit from the folders
importTypes(plugin);
// Provide plugin metadata from package.json
plugin.metadata = require('./package.json');
// Load a product
plugin.addProduct(require('./config/epinio'));
// Add Vuex stores
plugin.addDashboardStore(epinioMgmtStore.config.namespace, epinioMgmtStore.specifics, epinioMgmtStore.config);
plugin.addDashboardStore(epinioStore.config.namespace, epinioStore.specifics, epinioStore.config);
// Add Vue Routes
plugin.addRoutes(epinioRoutes);
// Add hooks to Vue navigation world
plugin.addNavHooks(onEnter, onLeave);
}

172
pkg/epinio/l10n/en-us.yaml Normal file
View File

@ -0,0 +1,172 @@
typeLabel:
namespaces: |-
{count, plural,
one { Namespaces }
other { Namespaces }
}
applications: |-
{count, plural,
one { Applications }
other { Applications }
}
services: |-
{count, plural,
one { Services }
other { Services }
}
configurations: |-
{count, plural,
one { Configurations }
other { Configurations }
}
epinio:
label: Epinio
tableHeaders:
namespace: Namespace
instances:
header: Epinio instances
none:
header: No instances of Epinio were found
description: To view an Epinio cluster be sure to import a Cluster where one is installed
tableHeaders:
api: URL
version: Version
explore: Explore
cluster: Rancher Cluster
applications:
tableHeaders:
route: Routes
boundConfigs: Bound Configs
deployedBy: Last Deployed By
detail:
counts:
label: Counts
config: Configs
routes: Routes
envVars: Environment Vars
routes:
label: Routes
deployment:
label: Deployment
summary: Summary
instances: Instances
memory: Memory
cpu: CPU
create:
title: Application
titleSubText: Epinio
instances: Instances
envvar:
title: Environment Variables
keyLabel: Name
valueLabel: Value
routes:
title: Routes
tooltip: Replace the default route (<app name>.<epinio domain>) with one or more custom routes
placeholder: e.g. my-custom-route.com/my-app
steps:
basics:
label: Details
subtext: Basic info about your app
source:
label: Source
subtext: Provide the source
folder:
label: Folder
file:
label: Folder
inputLabel: Folder
tooltip: This should contain your application files
archive:
label: Archive
file:
label: Archive File
inputLabel: File Name
tooltip: This should be a compressed file containing your application
builderimage:
label: Paketo Builder Image
tooltip: Paketo builder image to use for staging
default: Default Image
custom: Custom Image
containerUrl:
label: Container Image
url:
label: Container Image
inputLabel: Image
tooltip: Container Image for the app workload
gitUrl:
label: Git URL
url:
label: URL
inputLabel: URL
tooltip: URL of the Git Repository
branch:
label: Branch
inputLabel: Branch
tooltip: Branch to deploy
configurations:
label: Configurations
subtext: Bind a config
select:
placeholderWithOptions: Select configs to bind app to
placeholderNoOptions: There are no configs in this namespace
next: Create
progress:
label: Progress
subtext: Status of create
table:
stage:
label: Stage
description: Description
status: Status
run:
label: Run
action:
create:
label: Create
description: The Application will be created ready to deploy source to
gitFetch:
label: Fetch
description: Fetch the files at the git repository's branch
upload:
label: Upload
description: Upload the source for the Application
build:
label: Build
description: Build the source for the Application
deploy:
label: Deploy
description: Deploy and start the Application
actions:
shell:
label: App Shell
onlyShell:
label: Shell
viewAppLogs:
label: App Logs
viewStagingLogs:
label: Last Build Logs
restage:
label: Rebuild
restart:
label: Restart
wm:
containerName: 'Instance: {label}'
namespace:
tableHeaders:
appCount: Applications
configCount: Configs
name: Name
create: Create a Namespace
deleteWarning: All applications and configs in a namespace will be deleted.
configurations:
pairs:
label: Config Data
tooltip: Data made available to bound applications via the path <code>/configurations/&lt;configuration name&gt;/&lt;data name&gt;/</code>
tableHeaders:
boundApps: Bound Applications
variableCount: No. of variables
createBy: Created By
promptRemove:
unbind: Unbind from applications before deleting

View File

@ -0,0 +1,51 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import { EPINIO_TYPES } from '../types';
import Loading from '@shell/components/Loading';
export default {
name: 'EpinioConfigurationsList',
components: {
Loading,
ResourceTable,
},
async fetch() {
this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.APP });
await this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.CONFIGURATION });
},
props: {
schema: {
type: Object,
required: true,
},
},
computed: {
rows() {
return this.$store.getters['epinio/all'](EPINIO_TYPES.CONFIGURATION);
},
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<ResourceTable
v-bind="$attrs"
:rows="rows"
:schema="schema"
v-on="$listeners"
>
<template #cell:boundApps="{ row }">
<span v-if="row.applications.length">
<template v-for="(app, index) in row.applications">
<LinkDetail :key="app.id" :row="app" :value="app.meta.name" />
<span v-if="index < row.applications.length - 1" :key="app.id + 'i'">, </span>
</template>
</span>
<span v-else class="text-muted">&nbsp;</span>
</template>
</ResourceTable>
</div>
</template>

View File

@ -0,0 +1,203 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Masthead from '@shell/components/ResourceList/Masthead';
import Banner from '@shell/components/Banner';
import Card from '@shell/components/Card';
import { mapGetters, mapState } from 'vuex';
import LabeledInput from '@shell/components/form/LabeledInput.vue';
import { validateKubernetesName } from '@shell/utils/validators/kubernetes-name';
import AsyncButton from '@shell/components/AsyncButton';
import { _CREATE } from '@shell/config/query-params';
import { EPINIO_TYPES } from '../types';
import { epinioExceptionToErrorsArray } from '../utils/errors';
export default {
name: 'EpinioNamespaceList',
components: {
Banner,
ResourceTable,
Masthead,
Card,
LabeledInput,
AsyncButton
},
data() {
return {
showCreateModal: false,
errors: [],
validFields: { name: false },
value: { name: '' },
submitted: false,
mode: _CREATE,
touched: false,
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
validationPassed() {
const nameErrors = validateKubernetesName(this.value.name || '', this.t('epinio.namespace.name'), this.$store.getters, undefined, []);
return nameErrors.length === 0;
},
...mapState('action-menu', ['showPromptRemove']),
},
props: {
schema: {
type: Object,
required: true,
},
rows: {
type: Array,
required: true,
},
},
watch: {
showPromptRemove(oldState, newState) {
if (oldState === true && newState === false) {
// Refetch apps when namespace is deleted
this.$store.dispatch('findAll', { type: 'applications', opt: { force: true } });
}
},
'value.name'(neu) {
if (!neu?.length && !this.touched) {
this.touched = true;
return [];
}
const errors = validateKubernetesName(neu || '', this.t('epinio.namespace.name'), this.$store.getters, undefined, []);
this.errors = errors.length ? [errors.join(', ')] : [];
}
},
methods: {
async openCreateModal() {
this.showCreateModal = true;
// Focus on the name input field... after it's been displayed
this.$nextTick(() => this.$refs.namespaceName.focus());
// Create a skeleton namespace
this.value = await this.$store.dispatch(`epinio/create`, { type: EPINIO_TYPES.NAMESPACE });
},
closeCreateModal() {
this.showCreateModal = false;
this.errors = [];
this.touched = false;
},
async onSubmit(buttonCb) {
try {
await this.value.save();
this.closeCreateModal();
buttonCb(true);
} catch (e) {
this.errors = epinioExceptionToErrorsArray(e).map(JSON.stringify);
buttonCb(false);
}
},
}
};
</script>
<template>
<div>
<Masthead
:schema="schema"
:resource="'undefined'"
>
<template v-slot:createButton>
<button
class="btn role-primary"
@click="openCreateModal"
>
{{ t('generic.create') }}
</button>
</template>
</Masthead>
<ResourceTable
v-bind="$attrs"
:rows="rows"
:groupable="false"
:schema="schema"
key-field="_key"
v-on="$listeners"
/>
<div v-if="showCreateModal" class="modal">
<Card
class="modal-content"
:show-actions="true"
>
<h4 slot="title" v-html="t('epinio.namespace.create')" />
<div slot="body" class="model-body">
<LabeledInput
ref="namespaceName"
v-model="value.name"
:label="t('epinio.namespace.name')"
:mode="mode"
:required="true"
/>
<Banner v-for="(err, i) in errors" :key="i" color="error" :label="err" />
</div>
<div slot="actions" class="model-actions">
<button class="btn role-secondary mr-10" @click="closeCreateModal">
{{ t('generic.cancel') }}
</button>
<AsyncButton
:disabled="!validationPassed"
:mode="mode"
@click="onSubmit"
/>
</div>
</Card>
</div>
</div>
</template>
<style lang='scss' scoped>
.modal {
position: fixed; /* Stay in place */
z-index: 50; /* Sit on top */
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
border-radius: var(--border-radius);
.banner {
margin-bottom: 0;
}
}
.modal-content {
background-color: var(--default);
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 50%;
max-width: 500px;
.model-body {
min-height: 116px;
}
.model-actions {
justify-content: end;
display: flex;
flex: 1;
}
}
</style>

View File

@ -0,0 +1,149 @@
import Resource from '@shell/plugins/dashboard-store/resource-class';
import { APPLICATION_ACTION_STATE, APPLICATION_MANIFEST_SOURCE_TYPE, APPLICATION_SOURCE_TYPE } from '../types';
import { epinioExceptionToErrorsArray } from '../utils/errors';
import Vue from 'vue';
export const APPLICATION_ACTION_TYPE = {
CREATE: 'create',
GIT_FETCH: 'gitFetch',
UPLOAD: 'upload',
BUILD: 'build',
DEPLOY: 'deploy',
};
export default class ApplicationActionResource extends Resource {
// Props ---------------------------------------------------
run = true;
state = APPLICATION_ACTION_STATE.PENDING;
get name() {
return this.t(`epinio.applications.action.${ this.action }.label`);
}
get description() {
return this.t(`epinio.applications.action.${ this.action }.description`);
}
get stateObj() {
switch (this.state) {
case APPLICATION_ACTION_STATE.SUCCESS:
return {
name: 'succeeded',
error: false,
transitioning: false,
};
case APPLICATION_ACTION_STATE.RUNNING:
return {
name: 'pending',
error: false,
transitioning: true,
};
case APPLICATION_ACTION_STATE.FAIL:
return {
name: 'fail',
error: true,
transitioning: false,
message: this.stateMessage
};
case APPLICATION_ACTION_STATE.PENDING:
default:
return {
name: 'pending',
error: false,
transitioning: false,
};
}
}
// Private ---------------------------------------------------
async innerExecute(params) {
switch (this.action) {
case APPLICATION_ACTION_TYPE.CREATE:
await this.create(params);
break;
case APPLICATION_ACTION_TYPE.GIT_FETCH:
await this.gitFetch(params);
break;
case APPLICATION_ACTION_TYPE.UPLOAD:
await this.upload(params);
break;
case APPLICATION_ACTION_TYPE.BUILD:
await this.build(params);
break;
case APPLICATION_ACTION_TYPE.DEPLOY:
await this.deploy(params);
break;
}
}
async create() {
await this.application.create();
}
async upload({ source }) {
await this.application.storeArchive(source.archive.tarball);
}
async gitFetch({ source }) {
await this.application.gitFetch(source.gitUrl.url, source.gitUrl.branch);
}
async build({ source }) {
const { stage } = await this.application.stage(this.application.buildCache.store.blobUid, source.builderImage.value);
this.application.showStagingLog(stage.id);
await this.application.waitForStaging(stage.id);
}
async deploy({ source }) {
this.application.showAppLog();
const stageId = source.type === APPLICATION_SOURCE_TYPE.ARCHIVE ? this.application.buildCache.stage.stage.id : null;
const image = source.type === APPLICATION_SOURCE_TYPE.CONTAINER_URL ? source.container.url : this.application.buildCache.stage.image;
await this.application.deploy(stageId, image, this.createDeployOrigin(source));
}
createDeployOrigin(source) {
switch (source.type) {
case APPLICATION_SOURCE_TYPE.ARCHIVE:
case APPLICATION_SOURCE_TYPE.FOLDER:
return {
kind: APPLICATION_MANIFEST_SOURCE_TYPE.PATH,
path: source.archive.fileName
};
case APPLICATION_SOURCE_TYPE.CONTAINER_URL:
return {
kind: APPLICATION_MANIFEST_SOURCE_TYPE.CONTAINER,
container: source.container.url
};
case APPLICATION_SOURCE_TYPE.GIT_URL:
return {
kind: APPLICATION_MANIFEST_SOURCE_TYPE.GIT,
git: {
revision: source.gitUrl.branch,
repository: source.gitUrl.url
},
};
}
}
// Public ---------------------------------------------------
async execute(params) {
try {
Vue.set(this, 'state', APPLICATION_ACTION_STATE.RUNNING);
await this.innerExecute(params);
Vue.set(this, 'state', APPLICATION_ACTION_STATE.SUCCESS);
Vue.set(this, 'run', false);
} catch (err) {
Vue.set(this, 'state', APPLICATION_ACTION_STATE.FAIL);
Vue.set(this, 'stateMessage', epinioExceptionToErrorsArray(err)[0].toString());
throw err;
}
}
}

View File

@ -0,0 +1,51 @@
import Resource from '@shell/plugins/dashboard-store/resource-class';
import { EPINIO_PRODUCT_NAME } from '../types';
export default class ApplicationInstanceResource extends Resource {
get _availableActions() {
const isSingleProduct = !!this.$rootGetters['isSingleProduct'];
if (isSingleProduct) {
return [];
}
return [{
action: 'showAppShell',
label: this.t('epinio.applications.actions.onlyShell.label'),
icon: 'icon icon-fw icon-chevron-right',
enabled: this.ready,
}];
}
get state() {
switch (this.ready) {
case true:
return 'ready';
case false:
return 'notready';
default:
return 'pending';
}
}
showAppShell() {
const isSingleProduct = !!this.$rootGetters['isSingleProduct'];
if (isSingleProduct) {
return;
}
this.$dispatch('wm/open', {
id: `epinio-${ this.application.id }-app-shell`,
label: `${ this.application.meta.name } - App Shell`,
product: EPINIO_PRODUCT_NAME,
icon: 'chevron-right',
component: 'ApplicationShell',
attrs: {
application: this.application,
endpoint: this.application.linkFor('shell'),
initialInstance: this.name,
}
}, { root: true });
}
}

View File

@ -0,0 +1,558 @@
import { APPLICATION_MANIFEST_SOURCE_TYPE, EPINIO_PRODUCT_NAME, EPINIO_TYPES } from '../types';
import { createEpinioRoute } from '../utils/custom-routing';
import { formatSi } from '@shell/utils/units';
import { classify } from '@shell/plugins/dashboard-store/classify';
import EpinioResource from './epinio-resource';
// See https://github.com/epinio/epinio/blob/00684bc36780a37ab90091498e5c700337015a96/pkg/api/core/v1/models/app.go#L11
const STATES = {
CREATING: 'created',
STAGING: 'staging',
RUNNING: 'running',
ERROR: 'error',
};
// These map to @shell/plugins/dashboard-store/resource-class STATES
const STATES_MAPPED = {
[STATES.CREATING]: 'created',
[STATES.STAGING]: 'building',
[STATES.RUNNING]: 'running',
[STATES.ERROR]: 'error',
unknown: 'unknown',
};
export default class EpinioApplication extends EpinioResource {
buildCache = {};
get details() {
const res = [];
if (this.state === !!this.deployment) {
res.push({
label: 'Last Deployed By',
content: this.deployment.username,
}, {
label: 'Age',
content: this.deployment.createdAt,
formatter: 'LiveDate'
});
}
return res;
}
get listLocation() {
return this.$rootGetters['type-map/optionsFor'](this.type).customRoute || createEpinioRoute(`c-cluster-applications`, { cluster: this.$rootGetters['clusterId'] });
}
get parentLocationOverride() {
return this.listLocation;
}
get doneRoute() {
return this.listLocation.name;
}
get state() {
return STATES_MAPPED[this.status] || STATES_MAPPED.unknown;
}
get stateObj() {
switch (this.status) {
case STATES.CREATING:
return {
error: false,
transitioning: false,
message: this.statusmessage
};
case STATES.STAGING:
return {
error: false,
transitioning: true,
message: this.statusmessage
};
case STATES.RUNNING:
return {
error: false,
transitioning: false,
message: this.statusmessage
};
case STATES.ERROR:
return {
error: true,
transitioning: false,
message: this.statusmessage
};
default:
return {
error: true,
transitioning: false,
message: this.statusmessage
};
}
}
get _availableActions() {
const res = [];
const isSingleProduct = !!this.$rootGetters['isSingleProduct'];
const isRunning = [STATES.RUNNING].includes(this.status);
const showAppLog = isRunning;
const showStagingLog = !!this.stage_id;
const showAppShell = isRunning && !isSingleProduct;
if (showAppShell) {
res.push({
action: 'showAppShell',
label: this.t('epinio.applications.actions.shell.label'),
icon: 'icon icon-fw icon-chevron-right',
enabled: showAppShell,
});
}
res.push(
{
action: 'showAppLog',
label: this.t('epinio.applications.actions.viewAppLogs.label'),
icon: 'icon icon-fw icon-file',
enabled: showAppLog,
},
{
action: 'showStagingLog',
label: this.t('epinio.applications.actions.viewStagingLogs.label'),
icon: 'icon icon-fw icon-file',
enabled: showStagingLog,
},
);
if (showAppShell || showAppLog || showStagingLog) {
res.push({ divider: true });
}
res.push( {
action: 'restage',
label: this.t('epinio.applications.actions.restage.label'),
icon: 'icon icon-fw icon-backup',
enabled: !!this.deployment?.stage_id
},
{
action: 'restart',
label: this.t('epinio.applications.actions.restart.label'),
icon: 'icon icon-fw icon-refresh',
enabled: isRunning
},
{ divider: true },
...super._availableActions);
return res;
}
get nsLocation() {
return createEpinioRoute(`c-cluster-resource-id`, {
cluster: this.$rootGetters['clusterId'],
resource: EPINIO_TYPES.NAMESPACE,
id: this.meta.namespace
});
}
get links() {
return {
update: this.getUrl(),
self: this.getUrl(),
remove: this.getUrl(),
create: this.getUrl(this.meta?.namespace, null), // ensure name is null
store: `${ this.getUrl() }/store`,
stage: `${ this.getUrl() }/stage`,
deploy: `${ this.getUrl() }/deploy`,
logs: `${ this.getUrl() }/logs`.replace('/api/v1', '/wapi/v1'), // /namespaces/:namespace/applications/:app/logs
importGit: `${ this.getUrl() }/import-git`,
restart: `${ this.getUrl() }/restart`,
shell: `${ this.getUrl() }/exec`.replace('/api/v1', '/wapi/v1'), // /namespaces/:namespace/applications/:app/exec
};
}
getUrl(namespace = this.meta?.namespace, name = this.meta?.name) {
// Add baseUrl in a generic way
return this.$getters['urlFor'](this.type, this.id, { url: `/api/v1/namespaces/${ namespace }/applications/${ name || '' }` });
}
get configurations() {
const all = this.$getters['all'](EPINIO_TYPES.CONFIGURATION);
return (this.configuration.configurations || []).reduce((res, configName) => {
const s = all.find(allS => allS.meta.name === configName);
if (s) {
res.push(s);
}
return res;
}, []);
}
get envCount() {
return Object.keys(this.configuration?.environment || []).length;
}
get configCount() {
return this.configuration?.configurations.length;
}
get routeCount() {
return this.configuration?.routes.length;
}
get memory() {
return formatSi(this.deployment?.memoryBytes);
}
get desiredInstances() {
return this.deployment?.desiredreplicas;
}
set desiredInstances(neu) {
this.deployment.desiredreplicas = neu;
}
get readyInstances() {
return this.deployment?.readyreplicas;
}
get cpu() {
return this.deployment?.millicpus;
}
get sourceInfo() {
if (!this.origin) {
return undefined;
}
switch (this.origin.Kind) { // APPLICATION_MANIFEST_SOURCE_TYPE
case APPLICATION_MANIFEST_SOURCE_TYPE.PATH:
return { label: 'File system' };
case APPLICATION_MANIFEST_SOURCE_TYPE.GIT:
return {
label: 'Git',
details: [{
label: 'Url',
value: this.origin.git.repository
}, {
label: 'Revision',
value: this.origin.git.revision
}]
};
case APPLICATION_MANIFEST_SOURCE_TYPE.CONTAINER:
return {
label: 'Container',
details: [{
label: 'Image',
value: this.origin.Container
}]
};
default:
return undefined;
}
}
get instances() {
const instances = this.deployment?.replicas;
if (!instances) {
return [];
}
return Object.values(instances).map(i => classify(this.$ctx, {
...i,
id: i.name,
type: EPINIO_TYPES.APP_INSTANCE,
application: this
}));
}
get instanceMemory() {
const stats = this._instanceStats('memoryBytes');
const opts = {
suffix: 'iB',
firstSuffix: 'B',
increment: 1024,
};
stats.min = formatSi(stats.min, opts);
stats.max = formatSi(stats.max, opts);
stats.avg = formatSi(stats.avg, opts);
return stats;
}
get instanceCpu() {
return this._instanceStats('millicpus');
}
_instanceStats(prop) {
const stats = this.instances.reduce((res, r) => {
if (r[prop] >= res.max) {
res.max = r[prop];
}
if (r[prop] <= res.min) {
res.min = r[prop];
}
res.total += r[prop];
return res;
}, {
min: 0, max: 0, total: 0
});
const avg = this.instances.length ? (stats.total / this.instances.length).toFixed(2) : 0;
return {
...stats,
avg: avg === '0.00' ? 0 : avg,
};
}
// ------------------------------------------------------------------
// Methods here are required for generic components to handle `namespaced` concept
set metadata(metadata) {
this.meta = {
namespace: metadata.namespace,
name: metadata.name,
};
}
get metadata() {
return this.meta || {};
}
get namespaceLocation() {
return createEpinioRoute(`c-cluster-resource-id`, {
cluster: this.$rootGetters['clusterId'],
resource: EPINIO_TYPES.NAMESPACE,
id: this.meta.namespace,
});
}
// ------------------------------------------------------------------
trace(text, ...args) {
console.log(`### Application: ${ text }`, `${ this.meta.namespace }/${ this.meta.name }`, args.length ? args : '');// eslint-disable-line no-console
}
async create() {
this.trace('Create the application resource');
await this.followLink('create', {
method: 'post',
headers: {
'content-type': 'application/json',
accept: 'application/json'
},
data: {
name: this.meta.name,
configuration: {
instances: this.configuration.instances,
configurations: this.configuration.configurations,
environment: this.configuration.environment,
routes: this.configuration.routes,
}
}
});
}
async gitFetch(url, branch) {
this.trace('Downloading and storing git repo');
const formData = new FormData();
formData.append('giturl', url);
formData.append('gitrev', branch);
const res = await this.followLink('importGit', {
method: 'post',
headers: {
'content-type': 'application/x-www-form-urlencoded',
accept: 'gzip'
},
data: formData
});
this.buildCache.store = { blobUid: res.blobuid };
return res.blobuid;
}
async update() {
this.trace('Update the application resource');
await this.followLink('update', {
method: 'patch',
headers: {
'content-type': 'application/json',
accept: 'application/json'
},
data: {
instances: this.configuration.instances,
configurations: this.configuration.configurations,
environment: this.configuration.environment,
routes: this.configuration.routes,
}
});
}
async storeArchive(data) {
this.trace('Storing Application archive');
const formData = new FormData();
formData.append('file', data);
const res = await this.followLink('store', {
method: 'post',
headers: {
'content-type': 'multipart/form-data',
'File-Size': data.size,
},
data: formData
});
this.buildCache.store = { blobUid: res.blobuid };
return res.blobuid;
}
async stage(blobuid, builderImage) {
this.trace('Staging Application bits');
const { image, stage } = await this.followLink('stage', {
method: 'post',
headers: { 'content-type': 'application/json' },
data: {
app: {
name: this.meta.name,
namespace: this.meta.namespace
},
blobuid,
builderimage: builderImage
}
});
this.buildCache = this.buildCache || {};
this.buildCache.stage = {
stage,
image
};
return { image, stage };
}
async restage() {
const { stage } = await this.stage();
await this.forceFetch();
this.showStagingLog(stage.id);
}
showAppShell() {
const isSingleProduct = !!this.$rootGetters['isSingleProduct'];
if (isSingleProduct) {
return;
}
this.$dispatch('wm/open', {
id: `epinio-${ this.id }-app-shell`,
label: `${ this.meta.name } - App Shell`,
product: EPINIO_PRODUCT_NAME,
icon: 'chevron-right',
component: 'ApplicationShell',
attrs: {
application: this,
endpoint: this.linkFor('shell'),
initialInstance: this.instances[0].id
}
}, { root: true });
}
showAppLog() {
this.$dispatch('wm/open', {
id: `epinio-${ this.id }-app-logs`,
label: `${ this.meta.name } - App Logs`,
product: EPINIO_PRODUCT_NAME,
icon: 'file',
component: 'ApplicationLogs',
attrs: {
application: this,
endpoint: this.linkFor('logs')
}
}, { root: true });
}
showStagingLog(stageId = this.stage_id) {
if (!stageId) {
console.warn('Unable to show staging logs, no stage id');// eslint-disable-line no-console
}
// /namespaces/:namespace/staging/:stage_id/logs
let endpoint = `${ this.getUrl(this.meta?.namespace, stageId) }/logs`;
endpoint = endpoint.replace('/api/v1', '/wapi/v1');
endpoint = endpoint.replace('/applications', '/staging');
this.$dispatch('wm/open', {
id: `epinio-${ this.id }-logs-${ stageId }`,
label: `${ this.meta.name } - Build - ${ stageId }`,
product: EPINIO_PRODUCT_NAME,
icon: 'file',
component: 'ApplicationLogs',
attrs: {
application: this,
endpoint,
ansiToHtml: true
}
}, { root: true });
}
async waitForStaging(stageId) {
this.trace('Waiting for Application bits to be staged');
const opt = {
url: this.$getters['urlFor'](this.type, this.id, { url: `/api/v1/namespaces/${ this.meta.namespace }/staging/${ stageId }/complete` }),
method: 'get',
headers: {
'content-type': 'application/json',
accept: 'application/json'
},
};
await this.$dispatch('request', { opt, type: this.type });
}
async deploy(stageId, image, origin) {
this.trace('Deploying Application bits');
const stage = { };
if (stageId) {
stage.id = stageId;
}
const res = await this.followLink('deploy', {
method: 'post',
headers: { 'content-type': 'application/json' },
data: {
app: {
name: this.meta.name,
namespace: this.meta.namespace
},
stage,
image,
origin
}
});
this.route = res.route;
}
async restart() {
await this.followLink('restart', { method: 'post' });
await this.forceFetch();
this.showAppLog();
}
}

View File

@ -0,0 +1,101 @@
import { EPINIO_TYPES } from '../types';
import { createEpinioRoute } from '../utils/custom-routing';
import EpinioResource from './epinio-resource';
// POST - {"name":"my-service","data":{"foo":"bar"}}
// GET - { "boundapps": null, "name": "my-service" }
export default class EpinioConfiguration extends EpinioResource {
get links() {
return {
update: this.getUrl(),
self: this.getUrl(),
remove: this.getUrl(),
create: this.getUrl(this.meta?.namespace, null),
};
}
getUrl(namespace = this.meta?.namespace, name = this.meta?.name) {
return this.$getters['urlFor'](this.type, this.id, { url: `/api/v1/namespaces/${ namespace }/configurations/${ name || '' }` });
}
get applications() {
const all = this.$getters['all'](EPINIO_TYPES.APP);
return (this.configuration.boundapps || []).reduce((res, appName) => {
const a = all.find(allA => allA.meta.name === appName);
if (a) {
res.push(a);
}
return res;
}, []);
}
get variableCount() {
return Object.keys(this.configuration?.details || {}).length;
}
// ------------------------------------------------------------------
// Methods here are required for generic components to handle `namespaced` concept
set metadata(metadata) {
this.meta = {
namespace: metadata.namespace,
name: metadata.name,
};
}
get metadata() {
return this.meta;
}
get namespaceLocation() {
return createEpinioRoute(`c-cluster-resource-id`, {
cluster: this.$rootGetters['clusterId'],
resource: EPINIO_TYPES.NAMESPACE,
id: this.meta.namespace,
});
}
// ------------------------------------------------------------------
trace(text, ...args) {
console.log(`### Config: ${ text }`, `${ this.meta.namespace }/${ this.meta.name }`, args);// eslint-disable-line no-console
}
async create() {
this.trace('Create the config resource');
await this.followLink('create', {
method: 'post',
headers: {
'content-type': 'application/json',
accept: 'application/json'
},
data: {
name: this.meta.name,
data: { ...this.data }
}
});
}
async update() {
this.trace('Update the config resource');
await this.followLink('update', {
method: 'put',
headers: {
'content-type': 'application/json',
accept: 'application/json'
},
data: { ...this.data }
});
}
async remove() {
return await super.remove({ data: { unbind: true } });
}
// ------------------------------------------------------------------
}

View File

@ -0,0 +1,73 @@
import { createEpinioRoute } from '../utils/custom-routing';
import Resource from '@shell/plugins/dashboard-store/resource-class';
import { epinioExceptionToErrorsArray } from '../utils/errors';
export default class EpinioResource extends Resource {
get listLocation() {
return this.$rootGetters['type-map/optionsFor'](this.type).customRoute || createEpinioRoute(`c-cluster-resource`, {
cluster: this.$rootGetters['clusterId'],
resource: this.type,
});
}
async forceFetch() {
await this.$dispatch('find', {
type: this.type,
id: `${ this.meta.namespace }/${ this.meta.name }`,
opt: { force: true }
});
}
get detailLocation() {
const schema = this.$getters['schemaFor'](this.type);
const id = this.id?.replace(/.*\//, '');
return createEpinioRoute(`c-cluster-resource${ schema?.attributes?.namespaced ? '-namespace' : '' }-id`, {
cluster: this.$rootGetters['clusterId'],
resource: this.type,
id,
namespace: this.meta?.namespace,
});
}
// ------------------------------------------------------------------
get canClone() {
return false;
}
get canYaml() {
return false;
}
get canViewInApi() {
return false;
}
// ------------------------------------------------------------------
async _save(opt = {}) {
try {
return await super._save(opt);
} catch (e) {
throw epinioExceptionToErrorsArray(e);
}
}
async remove(opt = {}) {
if ( !opt.url ) {
opt.url = (this.links || {})['self'];
}
opt.method = 'delete';
try {
const res = await this.$dispatch('request', { opt, type: this.type });
console.log('### Resource Remove', this.type, this.id, res);// eslint-disable-line no-console
this.$dispatch('remove', this);
} catch (e) {
throw epinioExceptionToErrorsArray(e);
}
}
}

View File

@ -0,0 +1,60 @@
import EpinioResource from './epinio-resource';
export default class EpinioNamespace extends EpinioResource {
get links() {
return {
self: this.getUrl(),
remove: this.getUrl(),
};
}
async save() {
await this._save(...arguments);
const namespaces = await this.$dispatch('findAll', { type: this.type, opt: { force: true } });
// Find new namespace
// return new namespace
return namespaces.filter(n => n.name === this.name)?.[0];
}
get canClone() {
return false;
}
get canViewInApi() {
return false;
}
get canCustomEdit() {
return false;
}
get appCount() {
return this.apps?.length || 0;
}
get configCount() {
return this.configurations?.length || 0;
}
// ------------------------------------------------------------------
getUrl() {
// Add baseUrl in a generic way
return this.$getters['urlFor'](this.type, this.id, { url: `/api/v1/namespaces/${ this.name }` });
}
// ------------------------------------------------------------------
confirmRemove() {
return true;
}
get warnDeletionMessage() {
return this.t('epinio.namespace.deleteWarning');
}
get metadata() {
return { name: this.name };
}
}

31
pkg/epinio/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "epinio",
"description": "Application Development Engine for Kubernetes",
"version": "0.6.2",
"private": false,
"rancher": true,
"publishConfig": {
"registry": "http://localhost:4873"
},
"scripts": {
"dev": "./node_modules/.bin/nuxt dev",
"nuxt": "./node_modules/.bin/nuxt"
},
"engines": {
"node": ">=12"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.5",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-typescript": "^4.5.15",
"@vue/cli-service": "~4.5.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

View File

@ -0,0 +1,12 @@
<script lang="ts">
import ResourceDetail from '@shell/components/ResourceDetail/index.vue';
export default {
name: 'EpinioResourcedId',
components: { ResourceDetail },
};
</script>
<template>
<ResourceDetail />
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import ResourceDetail from '@shell/components/ResourceDetail/index.vue';
export default {
name: 'EpinioResourcedNamespaceId',
components: { ResourceDetail },
};
</script>
<template>
<ResourceDetail />
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import ResourceDetail from '@shell/components/ResourceDetail/index.vue';
export default {
name: 'EpinioResourceCreate',
components: { ResourceDetail },
};
</script>
<template>
<ResourceDetail />
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import ResourceList from '@shell/components/ResourceList/index.vue';
export default {
name: 'EpinioResourcedList',
components: { ResourceList },
};
</script>
<template>
<ResourceList />
</template>

View File

@ -0,0 +1,188 @@
<script lang="ts">
import Vue from 'vue';
import Application from '../../../../../models/applications';
import CreateEditView from '@shell/mixins/create-edit-view/impl';
import Loading from '@shell/components/Loading.vue';
import Wizard from '@shell/components/Wizard.vue';
import { EPINIO_TYPES } from '../../../../../types';
import { _CREATE } from '@shell/config/query-params';
import AppInfo from '../../../../../components/application/AppInfo.vue';
import AppSource, { EpinioAppSource } from '../../../../../components/application/AppSource.vue';
import AppConfiguration from '../../../../../components/application/AppConfiguration.vue';
import AppProgress from '../../../../../components/application/AppProgress.vue';
import { createEpinioRoute } from '../../../../../utils/custom-routing';
interface Data {
value?: Application,
initialValue?: Application,
mode: string,
errors: string[],
source?: EpinioAppSource,
steps: any[],
}
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
components: {
Loading,
Wizard,
AppInfo,
AppSource,
AppConfiguration,
AppProgress,
},
mixins: [
CreateEditView,
],
async fetch() {
await this.$store.dispatch('epinio/findAll', { type: EPINIO_TYPES.NAMESPACE });
this.originalModel = await this.$store.dispatch(`epinio/create`, { type: EPINIO_TYPES.APP });
// Dissassociate the original model & model. This fixes `Create` after refreshing page with SSR on
this.value = await this.$store.dispatch(`epinio/clone`, { resource: this.originalModel });
},
data() {
return {
value: undefined,
initialValue: undefined,
mode: _CREATE,
errors: [],
source: undefined,
steps: [{
name: 'basics',
label: this.t('epinio.applications.steps.basics.label'),
subtext: this.t('epinio.applications.steps.basics.subtext'),
ready: false,
}, {
name: 'source',
label: this.t('epinio.applications.steps.source.label'),
subtext: this.t('epinio.applications.steps.source.subtext'),
ready: false,
}, {
name: 'configurations',
label: this.t('epinio.applications.steps.configurations.label'),
subtext: this.t('epinio.applications.steps.configurations.subtext'),
ready: true,
nextButton: {
labelKey: 'epinio.applications.steps.configurations.next',
style: 'btn role-primary bg-warning'
}
}, {
name: 'progress',
label: this.t('epinio.applications.steps.progress.label'),
subtext: this.t('epinio.applications.steps.progress.subtext'),
ready: false,
previousButton: { disable: true }
}]
};
},
methods: {
set(obj: { [key: string]: string}, changes: { [key: string]: string}) {
Object.entries(changes).forEach(([key, value]: [string, any]) => {
Vue.set(obj, key, value);
});
},
updateInfo(changes: any) {
this.value.meta = this.value.meta || {};
this.value.configuration = this.value.configuration || {};
this.set(this.value.meta, changes.meta);
this.set(this.value.configuration, changes.configuration);
},
updateSource(changes: any) {
this.source = {};
this.set(this.source, changes);
},
updateConfigurations(changes: string[]) {
this.set(this.value.configuration, { configurations: changes });
},
cancel() {
this.$router.replace(this.value.listLocation);
},
finish() {
this.$router.replace(createEpinioRoute(`c-cluster-resource-id`, {
cluster: this.$store.getters['clusterId'],
resource: this.value.type,
id: `${ this.value.meta.namespace }/${ this.value.meta.name }`
}));
}
}
});
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div
v-else
class="application-wizard"
>
<Wizard
:steps="steps"
:banner-title="t('epinio.applications.create.title')"
:banner-title-subtext="t('epinio.applications.create.titleSubText')"
header-mode="create"
finish-mode="done"
:edit-first-step="true"
@cancel="cancel"
@finish="finish"
>
<template #basics>
<AppInfo
:application="value"
:mode="mode"
@change="updateInfo"
@valid="steps[0].ready = $event"
></AppInfo>
</template>
<template #source>
<AppSource
:application="value"
:source="source"
:mode="mode"
@change="updateSource"
@valid="steps[1].ready = $event"
></AppSource>
</template>
<template #configurations>
<AppConfiguration
:application="value"
:mode="mode"
@change="updateConfigurations"
></AppConfiguration>
</template>
<template #progress="{step}">
<AppProgress
:application="value"
:source="source"
:mode="mode"
:step="step"
></AppProgress>
</template>
</Wizard>
<!-- <br><br>
Debug<br>
Mode: {{ mode }}<br>
Value: {{ JSON.stringify(value) }}<br>
initialValue: {{ JSON.stringify(initialValue) }}<br>
source: {{ JSON.stringify(source) }}<br> -->
</div>
</template>
<style lang='scss' scoped>
.application-wizard {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 0;
}
</style>

View File

@ -0,0 +1,94 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import LinkDetail from '@shell/components/formatter/LinkDetail';
import { EPINIO_TYPES } from '../../../../types';
import { createEpinioRoute } from '../../../../utils/custom-routing';
export default {
components: {
Loading,
LinkDetail,
ResourceTable,
Masthead
},
async fetch() {
await this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.APP });
this.$store.dispatch(`epinio/findAll`, { type: EPINIO_TYPES.CONFIGURATION });
},
data() {
const resource = EPINIO_TYPES.APP;
const schema = this.$store.getters[`epinio/schemaFor`](resource);
return {
schema,
resource,
};
},
computed: {
headers() {
return this.$store.getters['type-map/headersFor'](this.schema);
},
groupBy() {
return this.$store.getters['type-map/groupByFor'](this.schema);
},
createLocation() {
return createEpinioRoute(`c-cluster-applications-createapp`, { cluster: this.$store.getters['clusterId'] });
},
rows() {
return this.$store.getters['epinio/all'](this.resource);
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Masthead
:schema="schema"
:resource="resource"
:create-location="createLocation"
/>
<ResourceTable
:schema="schema"
:rows="rows"
:headers="headers"
:group-by="groupBy"
>
<template #cell:configurations="{ row }">
<span v-if="row.configurations.length">
<template v-for="(configuration, index) in row.configurations">
<LinkDetail :key="configuration.id" :row="configuration" :value="configuration.meta.name" />
<span v-if="index < row.configurations.length - 1" :key="configuration.id + 'i'">, </span>
</template>
</span>
<span v-else class="text-muted">&nbsp;</span>
</template>
<template #cell:route="{ row }">
<span v-if="row.configuration.routes.length" class="route">
<template v-for="(route, index) in row.configuration.routes">
<a v-if="row.state === 'running'" :key="route.id" :href="`https://${route}`" target="_blank" rel="noopener noreferrer nofollow">{{ `https://${route}` }}</a>
<span v-else :key="route.id">{{ `https://${route}` }}</span>
<span v-if="index < row.configuration.routes.length - 1" :key="route.id + 'i'">, </span>
</template>
</span>
<span v-else class="text-muted">&nbsp;</span>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.route {
word-break: break-all;
}
</style>

181
pkg/epinio/pages/index.vue Normal file
View File

@ -0,0 +1,181 @@
<script lang="ts">
import Vue from 'vue';
import Loading from '@shell/components/Loading.vue';
import Link from '@shell/components/formatter/Link.vue';
import ResourceTable from '@shell/components/ResourceTable.vue';
import { EPINIO_MGMT_STORE, EPINIO_TYPES } from '../types';
import Resource from '@shell/plugins/dashboard-store/resource-class';
import AsyncButton from '@shell/components/AsyncButton.vue';
import { _MERGE } from '@shell/plugins/dashboard-store/actions';
interface Cluster extends Resource{
id: string,
state: string,
}
interface Data {
clustersSchema: any;
}
// Data, Methods, Computed, Props
export default Vue.extend<Data, any, any, any>({
components: {
AsyncButton, Loading, Link, ResourceTable
},
layout: 'plain',
async fetch() {
await this.$store.dispatch(`${ EPINIO_MGMT_STORE }/findAll`, { type: EPINIO_TYPES.INSTANCE });
this.clusters.forEach((c: Cluster) => this.testCluster(c));
},
data() {
return { clustersSchema: this.$store.getters[`${ EPINIO_MGMT_STORE }/schemaFor`](EPINIO_TYPES.INSTANCE) };
},
computed: {
cluster(): string {
return this.$route.params.cluster;
},
product(): string {
return this.$route.params.product;
},
canRediscover() {
return !this.clusters.find((c: Cluster) => c.state === 'updating');
},
clusters() {
return this.$store.getters[`${ EPINIO_MGMT_STORE }/all`](EPINIO_TYPES.INSTANCE);
}
},
methods: {
async rediscover(buttonCb: (success: boolean) => void) {
await this.$store.dispatch(`${ EPINIO_MGMT_STORE }/findAll`, { type: EPINIO_TYPES.INSTANCE, opt: { force: true, load: _MERGE } });
this.clusters.forEach((c: Cluster) => this.testCluster(c));
buttonCb(true);
},
setClusterState(cluster: Cluster, state: string, metadataStateObj: { transitioning: boolean, error: boolean, message: string }) {
Vue.set(cluster, 'state', state);
Vue.set(cluster, 'metadata', metadataStateObj);
},
testCluster(c: Cluster) {
// Call '/ready' on each cluster. If there's a network error there's a good chance the user has to permit an invalid cert
this.setClusterState(c, 'updating', {
state: {
transitioning: true,
message: 'Contacting...'
}
});
this.$store.dispatch('epinio/request', {
opt: { url: `/ready` }, clusterId: c.id, growlOnError: false
})
.then(() => this.$store.dispatch(`epinio/request`, {
opt: { url: `/api/v1/info` }, clusterId: c.id, growlOnError: false
}))
.then((res: any) => {
Vue.set(c, 'version', res?.version);
this.setClusterState(c, 'available', { state: { transitioning: false } });
})
.catch((e: Error) => {
if (e.message === 'Network Error') {
this.setClusterState(c, 'error', {
state: {
error: true,
message: `Network Error. If this instance uses an invalid certificate click on the URL above to bypass checks and refresh`
}
});
} else {
this.setClusterState(c, 'error', {
state: {
error: true,
message: `Failed to check the ready state: ${ e }`
}
});
}
});
}
}
});
</script>
<template>
<Loading v-if="$fetchState.pending" mode="main" />
<div v-else-if="clusters.length === 0" class="root">
<h2>{{ t('epinio.instances.none.header') }}</h2>
<p>{{ t('epinio.instances.none.description') }}</p>
</div>
<div v-else class="root">
<div class="epinios-table">
<h2>{{ t('epinio.instances.header') }}</h2>
<ResourceTable
:rows="clusters"
:schema="clustersSchema"
:table-actions="false"
:row-actions="false"
>
<template #header-left>
<AsyncButton
mode="refresh"
size="sm"
:disabled="!canRediscover"
style="display:inline-flex"
@click="rediscover"
/>
</template>
<template #cell:name="{row}">
<div class="epinio-row">
<n-link v-if="row.state === 'available'" :to="{name: 'epinio-c-cluster-applications', params: {cluster: row.id}}">
{{ row.name }}
</n-link>
<template v-else>
{{ row.name }}
</template>
</div>
</template>
<template #cell:api="{row}">
<div class="epinio-row">
<Link v-if="row.state !== 'available'" :row="row" :value="{ text: row.api, url: row.readyApi }" />
<template v-else>
{{ row.api }}
</template>
</div>
</template>
</ResourceTable>
</div>
</div>
</template>
<style lang="scss" scoped>
div.root {
align-items: center;
padding-top: 50px;
display: flex;
.epinios-table {
& > h4 {
padding-top: 50px;
padding-bottom : 20px;
}
min-width: 60%;
.epinio-row {
height: 40px;
display: flex;
align-items: center;
}
}
}
</style>

View File

@ -0,0 +1,43 @@
import { RouteConfig } from 'vue-router';
import { EPINIO_PRODUCT_NAME } from '../types';
import CreateApp from '../pages/c/_cluster/applications/createapp/index.vue';
import ListApp from '../pages/c/_cluster/applications/index.vue';
import ListEpinio from '../pages/index.vue';
import ListEpinioResource from '../pages/c/_cluster/_resource/index.vue';
import CreateEpinioResource from '../pages/c/_cluster/_resource/create.vue';
import ViewEpinioResource from '../pages/c/_cluster/_resource/_id.vue';
import ViewEpinioNsResource from '../pages/c/_cluster/_resource/_namespace/_id.vue';
const routes: RouteConfig[] = [{
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-applications-createapp`,
path: `/:product/c/:cluster/applications/createapp`,
component: CreateApp,
}, {
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-applications`,
path: `/:product/c/:cluster/applications`,
component: ListApp,
}, {
name: `${ EPINIO_PRODUCT_NAME }`,
path: `/:product`,
component: ListEpinio,
}, {
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`,
component: ListEpinioResource,
}, {
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-resource-create`,
path: `/:product/c/:cluster/:resource/create`,
component: CreateEpinioResource,
}, {
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-resource-id`,
path: `/:product/c/:cluster/:resource/:id`,
component: ViewEpinioResource,
}, {
name: `${ EPINIO_PRODUCT_NAME }-c-cluster-resource-namespace-id`,
path: `/:product/c/:cluster/:resource/:namespace/:id`,
component: ViewEpinioNsResource,
}];
export default routes;

View File

@ -0,0 +1,55 @@
import { SCHEMA } from '@shell/config/types';
import { handleSpoofedRequest } from '@shell/plugins/dashboard-store/actions';
import { normalizeType } from '@shell/plugins/dashboard-store/normalize';
import { EPINIO_MGMT_STORE, EPINIO_PRODUCT_NAME, EPINIO_TYPES } from '../../types';
export default {
async request({ rootGetters }: any, { opt }: any) {
const spoofedRes = await handleSpoofedRequest(rootGetters, EPINIO_MGMT_STORE, opt, EPINIO_PRODUCT_NAME);
if (spoofedRes) {
return spoofedRes;
}
throw new Error('Not Implemented');
},
async onLogout({ commit }: any) {
await commit('reset');
},
loadManagement(ctx: any) {
const { state, commit, rootGetters } = ctx;
// Use this to store non-cluster specific schemas. Cluster specific types are stored in epinio and are remove on cluster change
if ( state.managementReady) {
// Do nothing, it's already loaded
return;
}
// Load management style schemas
const spoofedSchemas = rootGetters['type-map/spoofedSchemas'](EPINIO_PRODUCT_NAME);
const instances = spoofedSchemas.find((schema: any) => schema.id === EPINIO_TYPES.INSTANCE);
const res = { data: [instances] };
res.data.forEach((schema) => {
schema._id = normalizeType(schema.id);
schema._group = normalizeType(schema.attributes?.group);
});
commit('loadAll', {
ctx,
type: SCHEMA,
data: res.data
});
// dispatch('loadSchemas')
commit('managementChanged', { ready: true });
},
watch() {
}
};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,30 @@
import { CoreStoreSpecifics, CoreStoreConfig } from '@shell/core/types';
import { EPINIO_MGMT_STORE } from '../../types';
import getters from './getters';
import mutations from './mutations';
import actions from './actions';
const epinioMgmtFactory = (): CoreStoreSpecifics => {
return {
state() {
return { managementReady: false };
},
getters: { ...getters },
mutations: { ...mutations },
actions: { ...actions },
};
};
const config: CoreStoreConfig = { namespace: EPINIO_MGMT_STORE };
/**
* `epiniomgmt` store contains resources that aren't epinio instance specific, for example the list of epinio instances
*/
export default {
specifics: epinioMgmtFactory(),
config
};

View File

@ -0,0 +1,6 @@
export default {
managementChanged(state: any, { ready }: any) {
state.managementReady = ready;
},
};

View File

@ -0,0 +1,247 @@
import { SCHEMA } from '@shell/config/types';
import { EPINIO_MGMT_STORE, EPINIO_PRODUCT_NAME, EPINIO_STANDALONE_CLUSTER_NAME, EPINIO_TYPES } from '../../types';
import { normalizeType } from '@shell/plugins/dashboard-store/normalize';
import { handleSpoofedRequest } from '@shell/plugins/dashboard-store/actions';
import { base64Encode } from '@shell/utils/crypto';
import { NAMESPACE_FILTERS } from '@shell/store/prefs';
import { createNamespaceFilterKeyWithId } from '@shell/utils/namespace-filter';
import { parse as parseUrl, stringify as unParseUrl } from '@shell/utils/url';
const createId = (schema: any, resource: any) => {
const name = resource.meta?.name || resource.name;
const namespace = resource.meta?.namespace || resource.namespace;
if (schema?.attributes?.namespaced && namespace) {
return `${ namespace }/${ name }`;
}
return name;
};
const epiniofy = (obj: any, schema: any, type: any) => ({
...obj,
// Note - these must be applied here ... so things that need an id before classifying have access to them
id: createId(schema, obj),
type,
});
export default {
remove({ commit }: any, obj: any ) {
commit('remove', obj);
},
async request({ rootGetters, dispatch, getters }: any, {
opt, type, clusterId, growlOnError = true
}: any) {
const spoofedRes = await handleSpoofedRequest(rootGetters, EPINIO_PRODUCT_NAME, opt, EPINIO_PRODUCT_NAME);
if (spoofedRes) {
return spoofedRes;
}
// @TODO queue/defer duplicate requests
opt.depaginate = opt.depaginate !== false;
opt.url = opt.url.replace(/\/*$/g, '');
const isSingleProduct = rootGetters['isSingleProduct'];
let ps = Promise.resolve(opt?.prependPath);
if (isSingleProduct) {
if (opt?.prependPath === undefined) {
ps = dispatch('findSingleProductCNSI').then((cnsi: any) => `/pp/v1/direct/r/${ cnsi?.guid }`);
}
} else {
ps = dispatch(`${ EPINIO_MGMT_STORE }/findAll`, { type: EPINIO_TYPES.INSTANCE }, { root: true }).then(() => '');
}
return await ps
.then((prependPath = opt?.prependPath) => {
if (isSingleProduct) {
const url = parseUrl(opt.url);
if (!url.path.startsWith(prependPath)) {
url.path = prependPath + url.path;
opt.url = unParseUrl(url);
}
} else {
const currentClusterId = clusterId || rootGetters['clusterId'];
const currentCluster = rootGetters[`${ EPINIO_MGMT_STORE }/byId`](EPINIO_TYPES.INSTANCE, currentClusterId);
opt.headers = {
...opt.headers,
Authorization: `Basic ${ base64Encode(`${ currentCluster.username }:${ currentCluster.password }`) }`
};
opt.url = `${ currentCluster.api }${ opt.url }`;
}
return (this as any).$axios(opt);
})
.then((res) => {
if ( opt.depaginate ) {
// @TODO but API never sends it
/*
return new Promise((resolve, reject) => {
const next = res.pagination.next;
if (!next ) [
return resolve();
}
dispatch('request')
});
*/
}
if ( opt.responseType ) {
return res;
} else {
const out = res.data || {};
const schema = getters.schemaFor(type);
if (Array.isArray(out)) {
res.data = { data: out.map(o => epiniofy(o, schema, type)) };
} else {
// `find` action turns this into `{data: out}`
res.data = epiniofy(out, schema, type);
}
return responseObject(res);
}
}).catch((err) => {
if ( !err || !err.response ) {
return Promise.reject(err);
}
const res = err.response;
// Go to the logout page for 401s, unless redirectUnauthorized specifically disables (for the login page)
if ( opt.redirectUnauthorized !== false && (process as any).client && res.status === 401 ) {
// return Promise.reject(err);
dispatch('auth/logout', opt.logoutOnError, { root: true });
} else if (growlOnError) {
dispatch('growl/fromError', { title: `Epinio Request to ${ opt.url }`, err }, { root: true });
}
if ( typeof res.data !== 'undefined' ) {
return Promise.reject(responseObject(res));
}
return Promise.reject(err);
});
function responseObject(res: any) {
let out = res.data;
if (typeof out === 'string') {
out = {};
}
if ( res.status === 204 || out === null || typeof out === 'string') {
out = {};
}
Object.defineProperties(out, {
_status: { value: res.status },
_statusText: { value: res.statusText },
_headers: { value: res.headers },
_req: { value: res.request },
_url: { value: opt.url },
});
return out;
}
},
async onLogout({ dispatch, commit }: any) {
await dispatch(`unsubscribe`);
await commit('reset');
},
loadSchemas: ( ctx: any ) => {
const { commit, rootGetters } = ctx;
const res = {
data: [{
product: EPINIO_PRODUCT_NAME,
id: EPINIO_TYPES.APP,
type: 'schema',
links: { collection: '/api/v1/applications' },
collectionMethods: ['get', 'post'],
resourceFields: { },
attributes: { namespaced: true }
}, {
product: EPINIO_PRODUCT_NAME,
id: EPINIO_TYPES.NAMESPACE,
type: 'schema',
links: { collection: '/api/v1/namespaces' },
collectionMethods: ['get', 'post'],
}, {
product: EPINIO_PRODUCT_NAME,
id: EPINIO_TYPES.APP_INSTANCE,
type: 'schema',
links: { collection: '/api/v1/na' },
collectionMethods: ['get'],
}, {
product: EPINIO_PRODUCT_NAME,
id: EPINIO_TYPES.CONFIGURATION,
type: 'schema',
links: { collection: '/api/v1/configurations' },
collectionMethods: ['get', 'post'],
resourceFields: { },
attributes: { namespaced: true }
}]
};
const spoofedSchemas = rootGetters['type-map/spoofedSchemas'](EPINIO_PRODUCT_NAME);
const excludeInstances = spoofedSchemas.filter((schema: any) => schema.id !== EPINIO_TYPES.INSTANCE);
res.data = res.data.concat(excludeInstances);
res.data.forEach((schema: any) => {
schema._id = normalizeType(schema.id);
schema._group = normalizeType(schema.attributes?.group);
});
commit('loadAll', {
ctx,
type: SCHEMA,
data: res.data
});
},
loadCluster: async( { dispatch, commit, rootGetters }: any, { id }: any ) => {
await dispatch(`findAll`, { type: EPINIO_TYPES.NAMESPACE });
await dispatch('cleanNamespaces', null, { root: true });
const key = createNamespaceFilterKeyWithId(id, EPINIO_PRODUCT_NAME);
const filters = rootGetters['prefs/get'](NAMESPACE_FILTERS)?.[key] || [];
commit('updateNamespaces', { filters }, { root: true });
},
findSingleProductCNSI: async( { dispatch, commit, getters }: any ) => {
const singleProductCNSI = getters['singleProductCNSI']();
if (singleProductCNSI) {
return singleProductCNSI;
}
const { data: endpoints } = await dispatch('request', {
opt: {
url: '/endpoints',
prependPath: '/pp/v1'
}
});
const cnsi = endpoints?.find((e: any) => e.name === EPINIO_STANDALONE_CLUSTER_NAME);
if (!cnsi) {
console.warn('Unable to find the CNSI guid of the Epinio Endpoint');// eslint-disable-line no-console
}
commit('singleProductCNSI', cnsi);
return cnsi;
}
};

View File

@ -0,0 +1,66 @@
import { EPINIO_TYPES } from '../../types';
import {
NAMESPACE_FILTER_SPECIAL as SPECIAL,
NAMESPACE_FILTER_ALL as ALL
} from '@shell/utils/namespace-filter';
export default {
urlFor: (state: any, getters: any) => (type: any, id: any, opt: any) => {
opt = opt || {};
type = getters.normalizeType(type);
let url = opt.url;
if ( !url ) {
const schema = getters.schemaFor(type);
if ( !schema ) {
throw new Error(`Unknown schema for type: ${ type }`);
}
url = schema.links.collection;
if ( id ) {
const slash = id.indexOf('/');
if (schema.attributes?.namespaced && slash > 0) {
const ns = id.slice(0, slash);
const realId = id.slice(slash + 1, id.length);
const type = url.indexOf(schema.id);
url = `${ url.slice(0, type) }namespaces/${ ns }/${ url.slice(type, url.length) }/${ realId }`;
} else {
url += `/${ id }`;
}
}
}
url = getters.urlOptions(url, opt);
return url;
},
urlOptions: () => (url: any, opt: any) => {
// This is where Epinio API filter, limit, sort will be applied
return url;
},
namespaceFilterOptions: (state: any, getters: any, rootState: any, rootGetters: any) => ({
addNamespace,
divider
}: any) => {
const out = [{
id: ALL,
kind: SPECIAL,
label: rootGetters['i18n/t']('nav.ns.all'),
}];
divider(out);
addNamespace(out, getters.all(EPINIO_TYPES.NAMESPACE));
return out;
},
singleProductCNSI: (state: any) => () => state.singleProductCNSI
};

View File

@ -0,0 +1,39 @@
import { CoreStoreSpecifics, CoreStoreConfig } from '@shell/core/types';
import getters from './getters';
import mutations from './mutations';
import actions from './actions';
import { actions as subscribeActions } from './subscribe-shims';
import { EPINIO_PRODUCT_NAME } from '../../types';
const epinioFactory = (): CoreStoreSpecifics => {
return {
state() {
return { };
},
getters: { ...getters },
mutations: { ...mutations },
actions: {
...actions,
...subscribeActions
},
};
};
const config: CoreStoreConfig = {
namespace: EPINIO_PRODUCT_NAME,
isClusterStore: true
};
/**
* `epinio` store is like a `cluster` store...
* .. it contains epinio instance specific resources that should be setup/reset when navigating to/away from an epinio instances
*/
export default {
specifics: epinioFactory(),
config
};

View File

@ -0,0 +1,7 @@
export default {
singleProductCNSI(state: any, singleProductCNSI: any) {
state.singleProductCNSI = singleProductCNSI;
}
};

View File

@ -0,0 +1,36 @@
import { _MERGE } from '@shell/plugins/dashboard-store/actions';
import PollerSequential from '@shell/utils/poller-sequential';
const polling: any = {};
const POLL_INTERVAL = 10000;
export const actions = {
unsubscribe() {
Object.entries(polling).forEach(([type, poll]: [any, any]) => {
console.warn('Epinio: Polling stopped for: ', type); // eslint-disable-line no-console
poll.stop();
delete polling[type];
});
},
watch({ dispatch, rootGetters }: any, { type }: any) {
if (rootGetters['type-map/isSpoofed'](type) || polling[type]) {
// Ignore spoofed
return;
}
console.warn('Epinio: Polling started for: ', type);// eslint-disable-line no-console
polling[type] = new PollerSequential(
async() => {
console.debug('Epinio: Polling: ', type); // eslint-disable-line no-console
// NOTE - In order for lists to automatically update resources opt to MERGE data in place instead of replace
// (in rancher land these are all handled individually, here we have bulk changes)
await dispatch('findAll', { type, opt: { force: true, load: _MERGE } });
},
POLL_INTERVAL,
5
);
polling[type].start();
}
};

64
pkg/epinio/tsconfig.json Normal file
View File

@ -0,0 +1,64 @@
{
"compilerOptions": {
"allowJs": true,
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"preserveSymlinks": true,
"types": [
"node",
"webpack-env"
],
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
],
"paths": {
"@shell/core/*": [
"../../shell/core/*"
],
"@shell/config/*": [
"../../shell/config/*"
],
"@shell/store/*": [
"../../shell/store/*"
],
"@shell/plugins/*": [
"../../shell/plugins/*"
],
"@shell/utils/*": [
"../../shell/utils/*"
],
"@shell/models/*": [
"../../shell/models/*"
],
"@pkg/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.d.ts",
"**/*.tsx",
"**/*.vue",
"../../shell/types/*.d.ts",
"../../shell/core/*.ts",
"../../shell/config/**/*.js",
"**/*.yaml",
"../../vue-shim.d.ts",
],
"exclude": [
"node_modules"
]
}

39
pkg/epinio/types.ts Normal file
View File

@ -0,0 +1,39 @@
export const EPINIO_PRODUCT_NAME = 'epinio';
export const EPINIO_MGMT_STORE = 'epiniomgmt';
// // An endpoint with this name is automatically created by the standalone backend
export const EPINIO_STANDALONE_CLUSTER_NAME = 'default';
export const EPINIO_TYPES = {
// From API
APP: 'applications',
NAMESPACE: 'namespaces',
CONFIGURATION: 'configurations',
// Internal
INSTANCE: 'instance',
APP_ACTION: 'application-action',
APP_INSTANCE: 'application-instance',
};
// // https://github.com/epinio/epinio/blob/7eb93b6dc735f8a6db26b8a242ae62a34877014c/pkg/api/core/v1/models/models.go#L96
export const APPLICATION_MANIFEST_SOURCE_TYPE = {
NONE: 0,
PATH: 1,
GIT: 2,
CONTAINER: 3,
};
export const APPLICATION_SOURCE_TYPE = {
CONTAINER_URL: 'container_url',
ARCHIVE: 'archive',
FOLDER: 'folder',
GIT_URL: 'git_url',
};
export const APPLICATION_ACTION_STATE = {
SUCCESS: 'success',
RUNNING: 'running',
FAIL: 'fail',
PENDING: 'pending',
};

View File

@ -0,0 +1,14 @@
import { EPINIO_PRODUCT_NAME } from '../types';
export const rootEpinioRoute = () => ({
name: EPINIO_PRODUCT_NAME,
params: { product: EPINIO_PRODUCT_NAME }
});
export const createEpinioRoute = (name: string, params: Object) => ({
name: `${ rootEpinioRoute().name }-${ name }`,
params: {
...rootEpinioRoute().params,
...params
}
});

View File

@ -0,0 +1,41 @@
import { EPINIO_TYPES } from '../types';
import { MANAGEMENT } from '@shell/config/types';
import { base64Decode } from '@shell/utils/crypto';
import { ingressFullPath } from '@shell/models/networking.k8s.io.ingress';
import { allHash } from '@shell/utils/promise';
export default {
async discover(store: any) {
const allClusters = await store.dispatch('management/findAll', { type: MANAGEMENT.CLUSTER }, { root: true });
const epinioClusters = [];
for (const c of allClusters.filter((c: any) => c.isReady)) {
try {
// Get the url first, if it has this it's highly likely it's an epinio cluster
const epinioIngress = await store.dispatch(`cluster/request`, { url: `/k8s/clusters/${ c.id }/v1/networking.k8s.io.ingresses/epinio/epinio` }, { root: true });
const url = ingressFullPath(epinioIngress, epinioIngress.spec.rules?.[0]);
const epinio: any = await allHash({ authData: store.dispatch(`cluster/request`, { url: `/k8s/clusters/${ c.id }/v1/secrets/epinio/default-epinio-user` }, { root: true }) });
const username = epinio.authData.data.username;
const password = epinio.authData.data.password;
epinioClusters.push({
id: c.id,
name: c.spec.displayName,
api: url,
readyApi: `${ url }/ready`,
username: base64Decode(username),
password: base64Decode(password),
type: EPINIO_TYPES.INSTANCE,
mgmtCluster: c
});
} catch (err) {
console.info(`Skipping epinio discovery for ${ c.spec.displayName }`, err); // eslint-disable-line no-console
}
}
return epinioClusters;
}
};

View File

@ -0,0 +1,26 @@
import { isArray } from '@shell/utils/array';
export function epinioExceptionToErrorsArray(err: any): any {
if (err?.errors?.length === 1) {
return epinioExceptionToErrorsArray(err?.errors[0]);
}
if ( err?.response?.data ) {
const body = err.response.data;
if ( body && body.message ) {
return [body.message];
} else {
return [err];
}
} else if (err.status && err.title) {
const title = err.title;
const detail = err.detail ? ` - ${ err.detail }` : '';
return [`${ title }${ detail }`];
} else if ( isArray(err) ) {
return err;
} else {
return [err];
}
}

4
pkg/epinio/vue-shim.ts Normal file
View File

@ -0,0 +1,4 @@
declare module '*.yaml' {
const data: any;
export default data;
}

1
pkg/epinio/vue.config.js Normal file
View File

@ -0,0 +1 @@
module.exports = require('./.shell/pkg/vue.config')(__dirname);

View File

@ -0,0 +1,439 @@
<script>
import AnsiUp from 'ansi_up';
import { addParams } from '@shell/utils/url';
import { LOGS_TIME, LOGS_WRAP, DATE_FORMAT, TIME_FORMAT } from '@shell/store/prefs';
import Checkbox from '@shell/components/form/Checkbox';
import AsyncButton from '@shell/components/AsyncButton';
import day from 'dayjs';
import Select from '@shell/components/form/Select';
import { escapeHtml, escapeRegex } from '@shell/utils/string';
import Socket, {
EVENT_CONNECTED,
EVENT_DISCONNECTED,
EVENT_MESSAGE,
EVENT_CONNECT_ERROR
} from '@shell/utils/socket';
import Window from '@shell/components/nav/WindowManager/Window';
import { downloadFile } from '@shell/utils/download';
import ApplicationSocketMixin from './ApplicationSocketMixin';
let lastId = 1;
const ansiup = new AnsiUp();
export default {
components: {
Window,
Checkbox,
AsyncButton,
Select
},
mixins: [ApplicationSocketMixin],
props: {
ansiToHtml: {
type: Boolean,
default: false,
},
},
data() {
return {
isFollowing: true,
timestamps: this.$store.getters['prefs/get'](LOGS_TIME),
wrap: this.$store.getters['prefs/get'](LOGS_WRAP),
search: '',
lines: [],
instance: ''
};
},
computed: {
instanceChoicesWithNone() {
return [
...this.instanceChoices,
{
label: 'No Instance Filter',
value: null
}
];
},
filtered() {
if ( !this.search && !this.instance) {
return this.lines;
}
const re = new RegExp(escapeRegex(this.search), 'img');
const out = [];
for ( const line of this.lines ) {
let msg = line.rawMsg;
if ( this.instance) {
const pod = msg.substring(1, msg.length);
if (!pod.startsWith(this.instance)) {
continue;
}
}
const matches = msg.match(re);
if ( !matches ) {
continue;
}
const parts = msg.split(re);
msg = '';
while ( parts.length || matches.length ) {
if ( parts.length ) {
msg += ansiup.ansi_to_html(parts.shift()); // This also escapes
}
if ( matches.length ) {
msg += `<span class="highlight">${ ansiup.ansi_to_html(matches.shift()) }</span>`;
}
}
out.push({
id: line.id,
time: line.time,
msg,
});
}
return out;
},
timeFormatStr() {
const dateFormat = escapeHtml( this.$store.getters['prefs/get'](DATE_FORMAT));
const timeFormat = escapeHtml( this.$store.getters['prefs/get'](TIME_FORMAT));
return `${ dateFormat } ${ timeFormat }`;
}
},
beforeDestroy() {
this.$refs.body.removeEventListener('scroll', this.boundUpdateFollowing);
this.cleanup();
},
async mounted() {
await this.connect();
this.boundUpdateFollowing = this.updateFollowing.bind(this);
this.$refs.body.addEventListener('scroll', this.boundUpdateFollowing);
this.boundFlush = this.flush.bind(this);
this.timerFlush = setInterval(this.boundFlush, 100);
},
methods: {
async getSocketUrl() {
const { url, token } = await this.getRootSocketUrl();
return addParams(url, { follow: true, authtoken: token });
},
async connect() {
if ( this.socket ) {
await this.socket.disconnect();
this.socket = null;
this.lines = [];
}
this.lines = [];
const url = await this.getSocketUrl();
this.socket = new Socket(url, true, 0);
this.socket.addEventListener(EVENT_CONNECTED, (e) => {
this.isOpen = true;
});
this.socket.addEventListener(EVENT_DISCONNECTED, (e) => {
this.isOpen = false;
});
this.socket.addEventListener(EVENT_CONNECT_ERROR, (e) => {
this.isOpen = false;
console.error('Connect Error', e); // eslint-disable-line no-console
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
let parsedData;
try {
parsedData = JSON.parse(e.detail.data);
} catch (e) {
console.warn('Unable to parse websocket data: ', e.detail.data); // eslint-disable-line no-console
return;
}
const { PodName, ContainerName, Message } = parsedData;
const line = `[${ PodName }] ${ ContainerName }: ${ Message }`;
this.backlog.push({
id: lastId++,
msg: this.ansiToHtml ? ansiup.ansi_to_html(line) : line,
rawMsg: line,
// time,
});
});
this.socket.connect();
},
flush() {
if ( this.backlog.length ) {
this.lines.push(...this.backlog);
this.backlog = [];
}
if ( this.isFollowing ) {
this.$nextTick(() => {
this.follow();
});
}
},
updateFollowing() {
const el = this.$refs.body;
this.isFollowing = el.scrollTop + el.clientHeight + 2 >= el.scrollHeight;
},
clear() {
this.lines = [];
},
download(btnCb) {
const date = new Date().toISOString().split('.')[0];
const fileName = `${ this.application.nameDisplay }-${ date }`;
downloadFile(fileName, this.lines.map(l => `${ l.rawMsg }`).join('\n'))
.then(() => btnCb(true))
.catch(() => btnCb(false));
},
follow() {
const el = this.$refs.body;
el.scrollTop = el.scrollHeight;
},
toggleWrap(on) {
this.wrap = on;
this.$store.dispatch('prefs/set', { key: LOGS_WRAP, value: this.wrap });
},
format(time) {
if ( !time ) {
return '';
}
return day(time).format(this.timeFormatStr);
},
cleanup() {
if ( this.socket ) {
this.socket.disconnect();
this.socket = null;
}
clearInterval(this.timerFlush);
},
},
};
</script>
<template>
<Window :active="active" :before-close="cleanup" class="epinio-app-log">
<template #title>
<div class="title-inner log-action ">
<div class="title-inner-left">
<Select
v-if="instanceChoices.length > 1"
v-model="instance"
:disabled="instanceChoices.length === 1"
class="containerPicker auto-width"
:options="instanceChoicesWithNone"
:clearable="true"
placement="top"
placeholder="Filter by Instance"
>
<template #selected-option="option">
<t
v-if="option"
k="epinio.applications.wm.containerName"
:label="option.label"
/>
</template>
</Select>
<button class="btn bg-primary ml-5" :disabled="isFollowing" @click="follow">
<t k="wm.containerLogs.follow" />
</button>
<button class=" btn bg-primary ml-5" @click="clear">
<t k="wm.containerLogs.clear" />
</button>
<AsyncButton class="ml-5" mode="download" @click="download" />
</div>
<div style="flex: 1;"></div>
<div class="title-inner-right">
<div class="status log-action text-center p-10" style="min-width: 80px;">
<t :class="{'text-success': isOpen, 'text-error': !isOpen}" :k="isOpen ? 'wm.connection.connected' : 'wm.connection.disconnected'" />
</div>
<div class="log-action ml-5">
<input v-model="search" class="input-sm" type="search" :placeholder="t('wm.containerLogs.search')" />
</div>
<div class="log-action ml-5">
<v-popover
trigger="click"
placement="top"
>
<button class="btn bg-primary">
<i class="icon icon-gear" />
</button>
<template slot="popover">
<div class="filter-popup">
<div><Checkbox :label="t('wm.containerLogs.wrap')" :value="wrap" @input="toggleWrap " /></div>
</div>
</template>
</v-popover>
</div>
</div>
</div>
</template>
<template #body>
<div
ref="body"
:class="{'logs-container': true, 'open': isOpen, 'closed': !isOpen, 'show-times': timestamps && filtered.length, 'wrap-lines': wrap}"
>
<table class="fixed" cellpadding="0" cellspacing="0">
<tbody class="logs-body">
<template v-if="filtered.length">
<tr v-for="line in filtered" :key="line.id">
<td :key="line.id + '-time'" class="time" v-html="format(line.time)" />
<td :key="line.id + '-msg'" class="msg" v-html="line.msg" />
</tr>
</template>
<tr v-else-if="search">
<td v-t="'wm.containerLogs.noMatch'" colspan="2" class="msg text-muted" />
</tr>
<tr v-else v-t="'wm.containerLogs.noData'" colspan="2" class="msg text-muted" />
</tbody>
</table>
</div>
</template>
</Window>
</template>
<style lang="scss">
.epinio-app-log {
.v-select.inline.vs--single.vs--open .vs__selected {
position: inherit;
}
}
</style>
<style lang="scss" scoped>
.title-inner {
display: flex;
flex-direction: row;
}
.title-inner {
display: flex;
flex-direction: row;
&-left, &-right {
display: flex;
flex-direction: row;
}
}
// .title-left {
// }
.logs-container {
height: 100%;
overflow: auto;
padding: 5px;
background-color: var(--logs-bg);
font-family: Menlo,Consolas,monospace;
color: var(--logs-text);
.closed {
opacity: 0.25;
}
.time {
white-space: nowrap;
display: none;
width: 0;
padding-right: 15px;
user-select: none;
}
&.show-times .time {
display: initial;
width: auto;
}
.msg {
white-space: nowrap;
.highlight {
color: var(--logs-highlight);
background-color: var(--logs-highlight-bg);
}
}
&.wrap-lines .msg {
white-space: normal;
}
}
.containerPicker {
::v-deep &.unlabeled-select {
display: inline-block;
min-width: 200px;
height: 30px;
width: initial;
}
}
.log-action {
button {
border: 0 !important;
min-height: 30px;
line-height: 30px;
}
> input {
height: 30px;
}
}
.status {
align-items: center;
display: flex;
min-width: 80px;
height: 30px;
}
.filter-popup {
> * {
margin-bottom: 10px;
}
}
.title-left {
display: flex;
}
</style>

View File

@ -0,0 +1,347 @@
<script>
import { allHash } from '@shell/utils/promise';
import { addParams } from '@shell/utils/url';
import { base64Decode, base64Encode } from '@shell/utils/crypto';
import Select from '@shell/components/form/Select';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
EVENT_DISCONNECTED,
EVENT_MESSAGE,
EVENT_CONNECT_ERROR,
} from '@shell/utils/socket';
import Window from '@shell/components/nav/WindowManager/Window';
import ApplicationSocketMixin from './ApplicationSocketMixin';
export default {
components: { Window, Select },
mixins: [ApplicationSocketMixin],
props: {
// The instance in the application to initially show
initialInstance: {
type: String,
default: null,
},
},
data() {
return {
instance: this.initialInstance || this.instanceChoices[0],
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpening: false,
keepAliveTimer: null,
};
},
computed: {
xtermConfig() {
return {
cursorBlink: true,
useStyle: true,
fontSize: 12,
};
},
},
watch: {
instance() {
this.connect();
},
height() {
this.fit();
},
},
beforeDestroy() {
clearInterval(this.keepAliveTimer);
this.cleanup();
},
async mounted() {
await this.setupTerminal();
await this.connect();
clearInterval(this.keepAliveTimer);
this.keepAliveTimer = setInterval(() => {
this.fit();
}, 60 * 1000);
},
methods: {
async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
selection: docStyle.getPropertyValue('--terminal-selection').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim(),
},
...this.xtermConfig,
});
this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm);
if (this.webglAddon) {
terminal.loadAddon(this.webglAddon);
}
this.fit();
this.flush();
terminal.onData((input) => {
const msg = `0${ base64Encode(input) }`;
this.write(msg);
});
this.terminal = terminal;
},
write(msg) {
if (this.isOpen) {
this.socket.send(msg);
} else {
this.backlog.push(msg);
}
},
clear() {
this.terminal.clear();
},
async getSocketUrl() {
const { url, token } = await this.getRootSocketUrl();
return addParams(url, {
authtoken: token,
instance: this.instance,
});
},
async connect() {
if (this.socket) {
await this.socket.disconnect();
this.socket = null;
this.terminal.reset();
}
const url = await this.getSocketUrl();
if (!url) {
return;
}
this.socket = new Socket(url, false, 0, 'base64.channel.k8s.io');
this.socket.addEventListener(EVENT_CONNECTING, (e) => {
this.isOpen = false;
this.isOpening = true;
});
this.socket.addEventListener(EVENT_CONNECT_ERROR, (e) => {
this.isOpen = false;
this.isOpening = false;
console.error('Connect Error', e); // eslint-disable-line no-console
});
this.socket.addEventListener(EVENT_CONNECTED, (e) => {
this.isOpen = true;
this.isOpening = false;
this.fit();
this.flush();
});
this.socket.addEventListener(EVENT_DISCONNECTED, (e) => {
this.isOpen = false;
this.isOpening = false;
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
const type = e.detail.data.substr(0, 1);
const msg = base64Decode(e.detail.data.substr(1));
if (`${ type }` === '1') {
this.terminal.write(msg);
} else {
console.error(msg); // eslint-disable-line no-console
}
});
this.socket.connect();
this.terminal.focus();
},
flush() {
const backlog = this.backlog.slice();
this.backlog = [];
for (const data of backlog) {
this.socket.send(data);
}
},
fit(arg) {
if (!this.fitAddon) {
return;
}
this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if (!this.isOpen) {
return;
}
const message = `4${ base64Encode(
JSON.stringify({
Width: Math.floor(cols),
Height: Math.floor(rows),
})
) }`;
this.socket.send(message);
},
cleanup() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
},
},
};
</script>
<template>
<Window :active="active" :before-close="cleanup" class="epinio-app-shell">
<template #title>
<Select
v-if="instanceChoices.length > 1"
v-model="instance"
:disabled="instanceChoices.length === 1"
class="containerPicker auto-width pull-left"
:options="instanceChoices"
:clearable="false"
placement="top"
>
<template #selected-option="option">
<t
v-if="option"
k="epinio.applications.wm.containerName"
:label="option.label"
/>
</template>
</Select>
<div class="pull-left ml-5">
<button class="btn btn-sm bg-primary" @click="clear">
<t k="wm.containerShell.clear" />
</button>
</div>
<div class="status pull-left">
<t v-if="isOpen" k="wm.connection.connected" class="text-success" />
<t
v-else-if="isOpening"
k="wm.connection.connecting"
class="text-warning"
:raw="true"
/>
<t v-else k="wm.connection.disconnected" class="text-error" />
</div>
</template>
<template #body>
<div class="shell-container" :class="{ open: isOpen, closed: !isOpen }">
<div ref="xterm" class="shell-body" />
<resize-observer @notify="fit" />
</div>
</template>
</Window>
</template>
<style lang="scss">
.epinio-app-shell {
.v-select.inline.vs--single.vs--open .vs__selected {
position: inherit;
}
}
</style>
<style lang="scss" scoped>
.text-warning {
animation: flasher 2.5s linear infinite;
}
@keyframes flasher {
50% {
opacity: 0;
}
}
.shell-container {
height: 100%;
overflow: hidden;
}
.shell-body {
padding: calc(2 * var(--outline-width));
height: 100%;
& > .terminal.focus {
outline: var(--outline-width) solid var(--outline);
}
}
.containerPicker {
::v-deep &.unlabeled-select {
display: inline-block;
min-width: 200px;
height: 30px;
width: initial;
}
}
.status {
align-items: center;
display: flex;
min-width: 80px;
height: 30px;
margin-left: 10px;
}
</style>

View File

@ -0,0 +1,76 @@
import { EPINIO_MGMT_STORE, EPINIO_PRODUCT_NAME, EPINIO_TYPES } from '../types';
export default {
props: {
// The definition of the tab itself
tab: {
type: Object,
required: true,
},
// Is this tab currently displayed
active: {
type: Boolean,
required: true,
},
// The height of the window
height: {
type: Number,
required: true,
},
// The application to connect to
application: {
type: Object,
required: true,
},
endpoint: {
type: String,
required: true,
},
},
data() {
return {
socket: null,
isOpen: false,
backlog: [],
};
},
computed: {
instanceChoices() {
return this.application.instances.map(i => i.id);
},
},
methods: {
async getRootSocketUrl() {
const { token } = await this.$store.dispatch(`epinio/request`, { opt: { url: '/api/v1/authtoken' } });
const isSingleProduct = !!this.$store.getters['isSingleProduct'];
let api = '';
let prependPath = '';
if (isSingleProduct) {
const cnsi = this.$store.getters[`${ EPINIO_PRODUCT_NAME }/singleProductCNSI`]();
prependPath = `/pp/v1/direct/ws/${ cnsi?.guid }`;
} else {
const currentClusterId = this.$store.getters['clusterId'];
const currentCluster = this.$store.getters[`${ EPINIO_MGMT_STORE }/byId`](EPINIO_TYPES.INSTANCE, currentClusterId);
api = currentCluster.api;
}
return {
url: `${ api }${ prependPath }${ this.endpoint }`.replace(/^http/, 'ws'),
token
};
}
}
};

View File

@ -43,6 +43,7 @@ generic:
overview: Overview
plusMore: "+ {n} more"
readFromFile: Read from File
readFromFolder: Read from Folder
register: Register
remove: Remove
resource: |-
@ -592,6 +593,10 @@ asyncButton:
action: Rotate
waiting: Rotating&hellip;
success: Rotated
run:
action: Run
waiting: Running&hellip;
success: Completed
snapshot:
action: Snapshot Now
waiting: Snapshot Initiated&hellip;
@ -4797,6 +4802,7 @@ validation:
apiKey: Required an "Api Key" to be set.
invalidCron: Invalid cron schedule
k8s:
name: Must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character (e.g. 'my-name', or '123-abc').
identifier:
emptyLabel: '"{key}" cannot have an empty key'
emptyPrefix: '"{key}" cannot have an empty prefix'

View File

@ -52,6 +52,14 @@ export default {
type: Object,
default: null
},
/**
* Reduce the vertial height by changed 'Used' for the resource name
*/
usedAsResourceName: {
type: Boolean,
defaut: false
}
},
computed: {
displayUnits() {
@ -84,16 +92,21 @@ export default {
<template>
<div class="consumption-gauge">
<h3 v-if="resourceName">
<h3 v-if="resourceName && !usedAsResourceName">
{{ resourceName }}
</h3>
<div class="numbers">
<!-- @slot Optional slot to use as the title rather than showing the resource name -->
<slot
name="title"
:amountTemplateValues="amountTemplateValues"
:formattedPercentage="formattedPercentage"
>
<span>{{ t('node.detail.glance.consumptionGauge.used') }}</span> <span>{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} <span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}</span></span>
<h4 v-if="usedAsResourceName">
{{ resourceName }}
</h4>
<span v-else>{{ t('node.detail.glance.consumptionGauge.used') }}</span>
<span>{{ t('node.detail.glance.consumptionGauge.amount', amountTemplateValues) }} <span class="ml-10 percentage">/&nbsp;{{ formattedPercentage }}</span></span>
</slot>
</div>
<div class="mt-10">

View File

@ -89,6 +89,13 @@ export default {
applyHooks: {
type: Function,
default: null,
},
// Used to prevent cancel and create buttons from moving
// as form validation errors appear and disappear.
minHeight: {
type: String,
default: ''
}
},
@ -111,17 +118,14 @@ export default {
computed: {
canSave() {
const { validationPassed, showAsForm } = this;
if (showAsForm) {
if (validationPassed) {
return true;
}
} else {
// Don't apply validation rules if the form is not shown.
if (!this.showAsForm) {
return true;
}
return false;
// Disable the save button if there are form validation
// errors while the user is typing.
return this.validationPassed;
},
canDiff() {
@ -345,6 +349,7 @@ export default {
<div
v-if="_selectedSubtype || !subtypes.length"
class="resource-container cru__content"
:style="[minHeight ? { 'min-height': minHeight } : {}]"
>
<slot />
</div>

View File

@ -124,6 +124,7 @@ export default {
border-radius: var(--border-radius);
margin: 10px;
position: relative;
word-break: break-all;
.close {
position: absolute;

View File

@ -222,29 +222,25 @@ export default {
},
remove(btnCB) {
if (this.doneLocation) {
// doneLocation will recompute to undefined when delete request completes
this.cachedDoneLocation = { ...this.doneLocation };
}
if (this.hasCustomRemove && this.$refs?.customPrompt?.remove) {
this.$refs.customPrompt.remove();
this.$refs.customPrompt.remove(btnCB);
return;
}
let goTo;
if (this.doneLocation) {
// doneLocation will recompute to undefined when delete request completes
goTo = { ...this.doneLocation };
}
const serialRemove = this.toRemove.some(resource => resource.removeSerially);
if (serialRemove) {
this.serialRemove(goTo, btnCB);
this.serialRemove(btnCB);
} else {
this.parallelRemove(goTo, btnCB);
this.parallelRemove(btnCB);
}
},
async serialRemove(goTo, btnCB) {
async serialRemove(btnCB) {
try {
const spoofedTypes = this.getSpoofedTypes(this.toRemove);
@ -254,35 +250,33 @@ export default {
await this.refreshSpoofedTypes(spoofedTypes);
if ( goTo && !isEmpty(goTo) ) {
this.currentRouter.push(goTo);
}
btnCB(true);
this.close();
this.done();
} catch (err) {
this.error = err;
btnCB(false);
}
},
async parallelRemove(goTo, btnCB) {
async parallelRemove(btnCB) {
try {
const spoofedTypes = this.getSpoofedTypes(this.toRemove);
await Promise.all(this.toRemove.map(resource => resource.remove()));
await this.refreshSpoofedTypes(spoofedTypes);
if ( goTo && !isEmpty(goTo) ) {
this.currentRouter.push(goTo);
}
btnCB(true);
this.close();
this.done();
} catch (err) {
this.error = err;
btnCB(false);
}
},
done() {
if ( this.cachedDoneLocation && !isEmpty(this.cachedDoneLocation) ) {
this.currentRouter.push(this.cachedDoneLocation);
}
this.close();
},
getSpoofedTypes(resources) {
const uniqueResourceTypes = uniq(this.toRemove.map(resource => resource.type));
@ -345,6 +339,8 @@ export default {
:value="toRemove"
:names="names"
:type="type"
@errors="e => error = e"
@done="done"
/>
<div v-if="needsConfirm" class="mt-10">
<span

View File

@ -13,7 +13,7 @@ import ChildHook, { BEFORE_SAVE_HOOKS } from '@shell/mixins/child-hook';
import { DATE_FORMAT, TIME_FORMAT } from '@shell/store/prefs';
import { escapeHtml } from '@shell/utils/string';
import day from 'dayjs';
import { sortBy } from '~shell/utils/sort';
import { sortBy } from '@shell/utils/sort';
export default {
components: {

View File

@ -1,6 +1,6 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import { colorForState, stateDisplay } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay } from '@shell/plugins/dashboard-store/resource-class';
import { NAME, NAMESPACE, STATE, TYPE } from '@shell/config/table-headers';
import { sortableNumericSuffix } from '@shell/utils/sort';
import { NAME as EXPLORER } from '@shell/config/product/explorer';

View File

@ -126,7 +126,7 @@ export default {
namespaceLocation() {
if (!this.isNamespace) {
return {
return this.value.namespaceLocation || {
name: 'c-cluster-product-resource-id',
params: {
cluster: this.$route.params.cluster,
@ -258,15 +258,16 @@ export default {
});
}
if ( !out.length ) {
// If there's only YAML, return nothing and the button group will be hidden entirely
return null;
if ( this.canViewYaml ) {
out.push({
labelKey: 'resourceDetail.masthead.yaml',
value: 'yaml',
});
}
out.push({
labelKey: 'resourceDetail.masthead.yaml',
value: 'yaml',
});
if ( out.length < 2 ) {
return null;
}
return out;
},

View File

@ -10,7 +10,7 @@ import { SCHEMA } from '@shell/config/types';
import { createYaml } from '@shell/utils/create-yaml';
import Masthead from '@shell/components/ResourceDetail/Masthead';
import DetailTop from '@shell/components/DetailTop';
import { clone, set, diff } from '@shell/utils/object';
import { clone, diff } from '@shell/utils/object';
import IconMessage from '@shell/components/IconMessage';
function modeFor(route) {
@ -105,6 +105,9 @@ export default {
const options = store.getters[`type-map/optionsFor`](resource);
this.showMasthead = [_CREATE, _EDIT].includes(mode) ? options.resourceEditMasthead : true;
const canViewYaml = options.canYaml;
if ( options.resource ) {
resource = options.resource;
}
@ -166,22 +169,13 @@ export default {
}
}
// Ensure labels & annotations exists, since lots of things need them
if ( !model.metadata ) {
set(model, 'metadata', {});
}
if ( !model.metadata.annotations ) {
set(model, 'metadata.annotations', {});
}
if ( !model.metadata.labels ) {
set(model, 'metadata.labels', {});
}
// Ensure common properties exists
model = await store.dispatch(`${ inStore }/cleanForDetail`, model);
const out = {
hasCustomDetail,
hasCustomEdit,
canViewYaml,
resource,
as,
yaml,
@ -292,6 +286,8 @@ export default {
const detailResource = options.resourceDetail || options.resource || resource;
const editResource = options.resourceEdit || options.resource || resource;
// FIXME: These aren't right... signature is (rawType, subType).. not (rawType, resourceId)
// Remove id? How does subtype get in (cluster/node)
this.detailComponent = this.$store.getters['type-map/importDetail'](detailResource, id);
this.editComponent = this.$store.getters['type-map/importEdit'](editResource, id);
},
@ -327,6 +323,7 @@ export default {
</div>
<div v-else>
<Masthead
v-if="showMasthead"
:resource="resource"
:value="liveModel"
:mode="mode"
@ -334,6 +331,7 @@ export default {
:as="as"
:has-detail="hasCustomDetail"
:has-edit="hasCustomEdit"
:can-view-yaml="canViewYaml"
:resource-subtype="resourceSubtype"
:parent-route-override="parentRouteOverride"
:store-override="storeOverride"

View File

@ -152,20 +152,23 @@ export default {
<div class="actions">
<slot name="extraActions">
</slot>
<n-link
v-if="hasEditComponent && _isCreatable"
:to="_createLocation"
class="btn role-primary"
>
{{ _createButtonlabel }}
</n-link>
<n-link
v-else-if="_isYamlCreatable"
:to="_yamlCreateLocation"
class="btn role-primary"
>
{{ t("resourceList.head.createFromYaml") }}
</n-link>
<slot name="createButton">
<n-link
v-if="hasEditComponent && _isCreatable"
:to="_createLocation"
class="btn role-primary"
>
{{ _createButtonlabel }}
</n-link>
<n-link
v-else-if="_isYamlCreatable"
:to="_yamlCreateLocation"
class="btn role-primary"
>
{{ t("resourceList.head.createFromYaml") }}
</n-link>
</slot>
</div>
</slot>
</div>

View File

@ -1,7 +1,7 @@
<script>
import SimpleBox from '@shell/components/SimpleBox';
import { COUNT } from '@shell/config/types';
import { colorForState } from '@shell/plugins/steve/resource-class';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
export function colorToCountName(color) {
switch (color) {
@ -17,8 +17,8 @@ export function colorToCountName(color) {
export function resourceCounts(store, resource) {
const inStore = store.getters['currentStore'](COUNT);
const clusterCounts = store.getters[`${ inStore }/all`](COUNT)[0].counts;
const summary = clusterCounts[resource]?.summary || {};
const clusterCounts = store.getters[`${ inStore }/all`](COUNT)?.[0]?.counts;
const summary = clusterCounts?.[resource]?.summary || {};
const counts = {
total: summary.count || 0,

View File

@ -18,7 +18,7 @@ export const defaultTableSortGenerationFn = (schema, $store) => {
const resource = schema.id;
const inStore = $store.getters['currentStore'](resource);
const generation = $store.getters[`${ inStore }/currentGeneration`](resource);
const generation = $store.getters[`${ inStore }/currentGeneration`]?.(resource);
if ( generation ) {
return `${ resource }/${ generation }`;

View File

@ -77,7 +77,7 @@ export default {
watch: {
async clusters(neu) {
this.clusterDetail = neu[0];
await this.$store.dispatch('loadCluster', this.clusterDetail.id);
await this.$store.dispatch('loadCluster', { id: this.clusterDetail.id });
this.clusterCounts = this.$store.getters[`cluster/all`](COUNT);
}
}

View File

@ -35,6 +35,13 @@ export default {
if a step has ready=true, the wizard also allows navigation *back* to it
hidden: Don't show step, though include in DOM (dynamic steps must be in DOM to determine if they will include themselves in wizard)
loading: Wizard will block until all steps are not loading
nextButton?: {
labelKey?: default to `wizard.next`
style?: defaults to `btn role-primary`
},
previousButton: {
disable: defaults to false
}
}
*/
steps: {
@ -83,6 +90,12 @@ export default {
default: null
},
// Verb shown in the header, defaults to finishMode
headerMode: {
type: String,
default: null
},
// The set of labels to display for the finish AsyncButton
finishMode: {
type: String,
@ -129,6 +142,10 @@ export default {
return false;
},
canPrevious() {
return !this.activeStep?.previousButton?.disable && (this.activeStepIndex > 1 || this.editFirstStep);
},
canNext() {
return (this.activeStepIndex < this.visibleSteps.length - 1) && this.activeStep.ready;
},
@ -147,6 +164,13 @@ export default {
visibleSteps() {
return this.steps.filter(step => !step.hidden);
},
nextButtonStyle() {
return this.activeStep.nextButton?.style || `btn role-primary`;
},
nextButtonLabel() {
return this.activeStep.nextButton?.labelKey || `wizard.next`;
}
},
@ -234,65 +258,67 @@ export default {
<div class="header">
<div class="title">
<div v-if="showBanner" class="top choice-banner">
<div v-show="initialTitle || activeStepIndex > 0" class="title">
<!-- Logo -->
<slot name="bannerTitleImage">
<div v-if="bannerImage" class="round-image">
<LazyImage :src="bannerImage" class="logo" />
<slot name="bannerTitle">
<div v-show="initialTitle || activeStepIndex > 0" class="title">
<!-- Logo -->
<slot name="bannerTitleImage">
<div v-if="bannerImage" class="round-image">
<LazyImage :src="bannerImage" class="logo" />
</div>
</slot>
<!-- Title with subtext -->
<div class="subtitle">
<h2 v-if="bannerTitle">
{{ bannerTitle }}
</h2>
<span v-if="bannerTitleSubtext" class="subtext">{{ bannerTitleSubtext }}</span>
</div>
</slot>
<!-- Title with subtext -->
<div class="subtitle">
<h2 v-if="bannerTitle">
{{ bannerTitle }}
</h2>
<span v-if="bannerTitleSubtext" class="subtext">{{ bannerTitleSubtext }}</span>
</div>
</div>
</slot>
<!-- Step number with subtext -->
<div v-if="activeStep && showSteps" class="subtitle">
<h2>{{ t(`asyncButton.${finishMode}.action`) }}: {{ t('wizard.step', {number:activeStepIndex+1}) }}</h2>
<h2>{{ t(`asyncButton.${headerMode || finishMode}.action`) }}: {{ t('wizard.step', {number:activeStepIndex+1}) }}</h2>
<slot name="bannerSubtext">
<span class="subtext">{{ activeStep.subtext || activeStep.label }}</span>
<span v-if="activeStep.subtext !== null" class="subtext">{{ activeStep.subtext || activeStep.label }}</span>
</slot>
</div>
</div>
</div>
<div class="step-sequence">
<ul
v-if="showSteps"
class="steps"
tabindex="0"
@keyup.right.stop="selectNext(1)"
@keyup.left.stop="selectNext(-1)"
>
<template v-for="(step, idx ) in visibleSteps">
<li
</slot>
<div class="step-sequence">
<ul
v-if="showSteps"
class="steps"
tabindex="0"
@keyup.right.stop="selectNext(1)"
@keyup.left.stop="selectNext(-1)"
>
<template v-for="(step, idx ) in visibleSteps">
<li
:id="step.name"
:key="step.name+'li'"
:class="{step: true, active: step.name === activeStep.name, disabled: !isAvailable(step)}"
role="presentation"
>
<span
:aria-controls="'step' + idx+1"
:aria-selected="step.name === activeStep.name"
role="tab"
class="controls"
@click.prevent="goToStep(idx+1, true)"
:id="step.name"
:key="step.name+'li'"
:class="{step: true, active: step.name === activeStep.name, disabled: !isAvailable(step)}"
role="presentation"
>
<span class="icon icon-lg" :class="{'icon-dot': step.name === activeStep.name, 'icon-dot-open':step.name !== activeStep.name}" />
<span>
{{ step.label }}
<span
:aria-controls="'step' + idx+1"
:aria-selected="step.name === activeStep.name"
role="tab"
class="controls"
@click.prevent="goToStep(idx+1, true)"
>
<span class="icon icon-lg" :class="{'icon-dot': step.name === activeStep.name, 'icon-dot-open':step.name !== activeStep.name}" />
<span>
{{ step.label }}
</span>
</span>
</span>
</li>
<div v-if="idx!==visibleSteps.length-1" :key="step.name" class="divider" />
</template>
</ul>
</li>
<div v-if="idx!==visibleSteps.length-1" :key="step.name" class="divider" />
</template>
</ul>
</div>
</div>
</div>
<div class="step-container">
<template v-for="step in steps">
<div v-if="step.name === activeStep.name || step.hidden" :key="step.name" class="step-container__step" :class="{'hide': step.name !== activeStep.name && step.hidden}">
@ -314,7 +340,7 @@ export default {
<div class="controls-steps">
<slot v-if="showPrevious" name="back" :back="back">
<button :disabled="!editFirstStep && activeStepIndex===1" type="button" class="btn role-secondary" @click="back()">
<button :disabled="!canPrevious" type="button" class="btn role-secondary" @click="back()">
<t k="wizard.previous" />
</button>
</slot>
@ -326,8 +352,8 @@ export default {
/>
</slot>
<slot v-else name="next" :next="next">
<button :disabled="!canNext" type="button" class="btn role-primary" @click="next()">
<t k="wizard.next" />
<button :disabled="!canNext" type="button" :class="nextButtonStyle" @click="next()">
<t :k="nextButtonLabel" />
</button>
</slot>
</div>
@ -354,14 +380,20 @@ $spacer: 10px;
border-bottom: var(--header-border-size) solid var(--header-border);
$minHeight: 75px;
& > .title {
flex: 1;
min-height: 75px;
min-height: $minHeight;
display: flex;
}
.step-sequence {
flex:1;
min-height: $minHeight;
display: flex;
.steps {
flex: 1;
margin: 0 30px;
display:flex;
justify-content: space-between;
@ -379,6 +411,10 @@ $spacer: 10px;
flex-grow: 1;
align-items: center;
& > span > span:last-of-type {
padding-bottom: 0;
}
&:last-of-type{
flex-grow: 0;
}
@ -389,8 +425,10 @@ $spacer: 10px;
align-items: center;
width: 40px;
overflow: visible;
padding-top: 15px;
& > span {
padding-bottom: 10px;
padding-bottom: 5px;
margin-bottom: 5px;
white-space: nowrap;
}
}
@ -418,7 +456,7 @@ $spacer: 10px;
flex-basis: 100%;
border-top: 1px solid var(--border);
position: relative;
top: 5px;
top: 28px;
}
}
}
@ -445,7 +483,7 @@ $spacer: 10px;
justify-content: space-evenly;
& > .subtitle {
margin: 0 20px;
margin-right: 20px;
}
}

View File

@ -1,5 +1,5 @@
<script>
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/dashboard-store/resource-class';
import SortableTable from '@shell/components/SortableTable';
import { randomStr } from '~shell/utils/string';

View File

@ -1,5 +1,5 @@
<script>
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/dashboard-store/resource-class';
import SortableTable from '@shell/components/SortableTable';
import { NAME as EXPLORER } from '@shell/config/product/explorer';
import { FLEET as FLEET_ANNOTATIONS } from '@shell/config/labels-annotations';

View File

@ -1,7 +1,7 @@
<script>
import { sortBy } from '@shell/utils/sort';
import { get } from '@shell/utils/object';
import { stateSort } from '~shell/plugins/steve/resource-class';
import { stateSort } from '@shell/plugins/dashboard-store/resource-class';
export default {

View File

@ -1,6 +1,6 @@
<script>
import capitalize from 'lodash/capitalize';
import { STATES, STATES_ENUM } from '@shell/plugins/steve/resource-class';
import { STATES, STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class';
import FleetStatus from '@shell/components/fleet/FleetStatus';
const getResourceDefaultState = (labelGetter, stateKey) => {

View File

@ -1,7 +1,7 @@
<script>
import capitalize from 'lodash/capitalize';
import CountBox from '@shell/components/CountBox';
import { STATES } from '@shell/plugins/steve/resource-class';
import { STATES } from '@shell/plugins/dashboard-store/resource-class';
export default {

View File

@ -47,7 +47,12 @@ export default {
readAsDataUrl: {
type: Boolean,
default: false
default: false
},
rawData: {
type: Boolean,
default: false
}
},
@ -77,6 +82,15 @@ export default {
}
}
}
if (this.rawData) {
const unboxedContents = !this.multiple && files.length === 1 ? files[0] : files;
this.$emit('selected', unboxedContents);
return;
}
try {
const asyncFileContents = files.map(this.getFileContents);
const fileContents = await Promise.all(asyncFileContents);

View File

@ -75,11 +75,12 @@ export default {
type: String,
default: '',
},
},
data() {
return {
selected: this.selectValue || this.options[0],
selected: this.selectValue || this.options[0].value,
string: this.textValue,
};
},
@ -116,7 +117,7 @@ export default {
v-if="selectLabel"
v-model="selected"
:label="selectLabel"
:class="{ 'in-input': !isView }"
:class="{ 'in-input': !isView}"
:options="options"
:searchable="false"
:clearable="false"
@ -305,4 +306,5 @@ export default {
}
}
}
</style>

View File

@ -43,6 +43,12 @@ export default {
type: String,
default: ''
},
titleProtip: {
type: String,
default: ''
},
protip: {
type: [String, Boolean],
default() {
@ -420,7 +426,6 @@ export default {
return entry;
});
}
this.$emit('input', out);
},
onPaste(index, event, pastedValue) {
@ -471,6 +476,7 @@ export default {
<slot name="title">
<h3>
{{ title }}
<i v-if="titleProtip" v-tooltip="titleProtip" class="icon icon-info" />
</h3>
</slot>
</div>

View File

@ -62,6 +62,13 @@ export default {
}
},
data() {
return {
updated: false,
validationErrors: '',
};
},
computed: {
onInput() {
return this.delay ? debounce(this.delayInput, this.delay) : this.delayInput;
@ -156,20 +163,17 @@ export default {
<template>
<div
class="labeled-input"
:class="{
'labeled-input': true,
focused,
[mode]: true,
disabled: isDisabled,
[status]: status,
suffix: hasSuffix,
'has-tooltip': hasTooltip,
'compact-input': isCompact,
hideArrows
}"
>
<slot name="label">
<label v-if="hasLabel">
<label>
<t v-if="labelKey" :k="labelKey" />
<template v-else-if="label">{{ label }}</template>
@ -212,6 +216,7 @@ export default {
@blur="onBlur"
/>
</slot>
<slot name="suffix" />
<LabeledTooltip
v-if="tooltipKey && !focused"
@ -228,27 +233,13 @@ export default {
<label v-if="cronHint" class="cron-label">{{ cronHint }}</label>
<label v-if="subLabel" class="sub-label">{{ subLabel }}</label>
</div>
</div>
</template>
<style scoped lang="scss">
.labeled-input.view {
input {
text-overflow: ellipsis;
}
}
.hideArrows {
/* Hide arrows on number input when it overlaps with the unit */
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
<style>
.validation-message {
padding: 5px;
position: absolute;
bottom: -35px;
}
</style>

View File

@ -42,7 +42,6 @@ export default {
type: Array,
default: () => [],
},
nameLabel: {
type: String,
default: 'nameNsDescription.name.label',
@ -133,7 +132,7 @@ export default {
horizontal: {
type: Boolean,
default: true,
}
},
},
data() {
@ -192,7 +191,7 @@ export default {
namespaces() {
const inStore = this.$store.getters['currentStore'](this.namespaceType);
const choices = this.$store.getters[`${ inStore }/all`](this.namespaceType);
const choices = this.namespacesOverride || this.$store.getters[`${ inStore }/all`](this.namespaceType);
const out = sortBy(
choices.filter( this.namespaceFilter || ((choice) => {

View File

@ -1,7 +1,7 @@
<script>
import LabeledInput from '@shell/components/form/LabeledInput';
import Checkbox from '@shell/components/form/Checkbox';
import { _EDIT, _VIEW } from '~/config/query-params';
import { _EDIT, _VIEW } from '@shell/config/query-params';
export default ({

View File

@ -356,8 +356,6 @@ export default {
</button>
</div>
</template>
</div>
</template>
<style lang='scss' scoped>
.var-row{

View File

@ -1,6 +1,6 @@
<script>
import ProgressBarMulti from '@shell/components/ProgressBarMulti';
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay, stateSort } from '@shell/plugins/dashboard-store/resource-class';
import { sortBy } from '@shell/utils/sort';
export default {

View File

@ -1,6 +1,6 @@
<script>
import BadgeState from '@shell/components/BadgeState';
import { colorForState, stateDisplay } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay } from '@shell/plugins/dashboard-store/resource-class';
export default {
components: { BadgeState },
props: {

View File

@ -1,7 +1,7 @@
<script>
import ProgressBarMulti from '@shell/components/ProgressBarMulti';
import { ucFirst } from '@shell/utils/string';
import { colorForState, stateSort } from '@shell/plugins/steve/resource-class';
import { colorForState, stateSort } from '@shell/plugins/dashboard-store/resource-class';
import { sortBy } from '@shell/utils/sort';
export default {

View File

@ -1,6 +1,6 @@
<script>
import BadgeState from '@shell/components/BadgeState';
import { stateDisplay } from '@shell/plugins/steve/resource-class';
import { stateDisplay } from '@shell/plugins/dashboard-store/resource-class';
const ACTIVE = 'healthy';
const WARNING = 'warning';

View File

@ -9,7 +9,7 @@ export default {
computed: {
string() {
return this.value.join(', ');
return this.value ? this.value.join(', ') : '';
}
},
};

View File

@ -1,7 +1,7 @@
<script>
import day from 'dayjs';
import BadgeState from '@shell/components/BadgeState';
import { colorForState, stateDisplay } from '@shell/plugins/steve/resource-class';
import { colorForState, stateDisplay } from '@shell/plugins/dashboard-store/resource-class';
import { safeSetTimeout } from '@shell/utils/time';
export default {

View File

@ -46,14 +46,14 @@ export default {
const shellShortcut = '(Ctrl+`)';
return {
show: false,
showTooltip: false,
show: false,
showTooltip: false,
kubeConfigCopying: false,
searchShortcut,
shellShortcut,
VIRTUAL,
LOGGED_OUT,
harvesterLogo: require('~shell/assets/images/providers/harvester.svg'),
harvesterLogo: require('~shell/assets/images/providers/harvester.svg'),
};
},
@ -301,6 +301,7 @@ export default {
</template>
</div>
<div v-if="currentProduct && !currentProduct.showClusterSwitcher" class="cluster">
<img v-if="currentProduct.iconHeader" v-bind="$attrs" :src="currentProduct.iconHeader" class="cluster-os-logo mr-10" style="width: 44px; height: 36px;" />
<div class="product-name">
{{ prod }}
</div>

View File

@ -6,12 +6,17 @@ import { sortBy } from '@shell/utils/sort';
import { isArray, addObjects, findBy, filterBy } from '@shell/utils/array';
import { NAME as HARVESTER } from '@shell/config/product/harvester';
import {
ALL_USER, ALL, ALL_SYSTEM, ALL_ORPHANS, NAMESPACED_YES, NAMESPACED_NO
} from '@shell/store';
NAMESPACE_FILTER_SPECIAL as SPECIAL,
NAMESPACE_FILTER_ALL_USER as ALL_USER,
NAMESPACE_FILTER_ALL as ALL,
NAMESPACE_FILTER_ALL_SYSTEM as ALL_SYSTEM,
NAMESPACE_FILTER_ALL_ORPHANS as ALL_ORPHANS,
NAMESPACE_FILTER_NAMESPACED_YES as NAMESPACED_YES,
NAMESPACE_FILTER_NAMESPACED_NO as NAMESPACED_NO,
createNamespaceFilterKey,
} from '@shell/utils/namespace-filter';
import { KEY } from '@shell/utils/platform';
const SPECIAL = 'special';
export default {
data() {
return {
@ -24,7 +29,7 @@ export default {
},
computed: {
...mapGetters(['isVirtualCluster', 'isSingleVirtualCluster', 'isMultiVirtualCluster']),
...mapGetters(['isVirtualCluster', 'isSingleVirtualCluster', 'isMultiVirtualCluster', 'currentProduct']),
hasFilter() {
return this.filter.length > 0;
@ -75,10 +80,23 @@ export default {
};
},
key() {
return createNamespaceFilterKey(this.$store.getters['clusterId'], this.currentProduct);
},
options() {
const t = this.$store.getters['i18n/t'];
let out = [];
if (this.currentProduct.customNamespaceFilter) {
// Sometimes the component can show before the 'currentProduct' has caught up, so access the product via the getter rather
// than caching it in the `fetch`
return this.$store.getters[`${ this.currentProduct.inStore }/namespaceFilterOptions`]({
addNamespace,
divider
});
}
if (!this.isVirtualCluster) {
out = [
{
@ -108,7 +126,7 @@ export default {
},
];
divider();
divider(out);
}
const inStore = this.$store.getters['currentStore'](NAMESPACE);
@ -165,7 +183,7 @@ export default {
if (firstProject) {
firstProject = false;
} else {
divider();
divider(out);
}
out.push({
@ -176,14 +194,14 @@ export default {
const forThisProject = namespacesByProject[id] || [];
addNamespace(forThisProject);
addNamespace(out, forThisProject);
}
const orphans = namespacesByProject[null];
if (orphans.length) {
if (!firstProject) {
divider();
divider(out);
}
out.push({
@ -193,15 +211,15 @@ export default {
disabled: true,
});
addNamespace(orphans);
addNamespace(out, orphans);
}
} else {
addNamespace(namespaces);
addNamespace(out, namespaces);
}
return out;
function addNamespace(namespaces) {
function addNamespace(out, namespaces) {
if (!isArray(namespaces)) {
namespaces = [namespaces];
}
@ -218,7 +236,7 @@ export default {
);
}
function divider() {
function divider(out) {
out.push({
kind: 'divider',
label: `Divider ${ out.length }`,
@ -234,8 +252,8 @@ export default {
value: {
get() {
const prefs = this.$store.getters['prefs/get'](NAMESPACE_FILTERS);
const clusterId = this.$store.getters['clusterId'];
const values = prefs[clusterId] || [ALL_USER];
const prefDefault = this.currentProduct.customNamespaceFilter ? [] : [ALL_USER];
const values = prefs[this.key] || prefDefault;
const options = this.options;
// Remove values that are not valid options
@ -275,13 +293,16 @@ export default {
// If there was something selected and you remove it, go back to user by default
// Unless it was user or all
if (neu.length === 0 && !hadUser && !hadAll) {
ids = [ALL_USER];
ids = this.currentProduct.customNamespaceFilter ? [] : [ALL_USER];
} else {
ids = neu.map(x => x.id);
}
this.$nextTick(() => {
this.$store.dispatch('switchNamespaces', ids);
this.$store.dispatch('switchNamespaces', {
ids,
key: this.key
});
});
},
}

View File

@ -97,7 +97,7 @@ export default {
multiClusterApps() {
const options = this.options;
return options.filter(opt => opt.inStore === 'management' && opt.category !== 'configuration' && opt.category !== 'legacy');
return options.filter(opt => (opt.inStore === 'management' || opt.isMultiClusterApp) && opt.category !== 'configuration' && opt.category !== 'legacy');
},
legacyApps() {
@ -129,7 +129,7 @@ export default {
const entries = this.activeProducts.map((p) => {
// Try product-specific index first
const to = {
const to = p.to || {
name: `c-cluster-${ p.name }`,
params: { cluster }
};
@ -140,14 +140,15 @@ export default {
}
return {
label: this.$store.getters['i18n/withFallback'](`product."${ p.name }"`, null, ucFirst(p.name)),
icon: `icon-${ p.icon || 'copy' }`,
value: p.name,
removable: p.removable !== false,
inStore: p.inStore || 'cluster',
weight: p.weight || 1,
category: p.category || 'none',
label: this.$store.getters['i18n/withFallback'](`product."${ p.name }"`, null, ucFirst(p.name)),
icon: `icon-${ p.icon || 'copy' }`,
value: p.name,
removable: p.removable !== false,
inStore: p.inStore || 'cluster',
weight: p.weight || 1,
category: p.category || 'none',
to,
isMultiClusterApp: p.isMultiClusterApp,
};
});

View File

@ -9,6 +9,7 @@ export default {
dragOffset: 0,
reportedHeight: this.height,
firstHeight: true,
component: { },
};
},
@ -158,7 +159,16 @@ export default {
},
componentFor(tab) {
return require(`@shell/components/nav/WindowManager/${ tab.component }`).default;
if (this.component[tab.component] === undefined) {
if (this.$store.getters['type-map/hasCustomWindowComponent'](tab.component)) {
this.component[tab.component] = this.$store.getters['type-map/importWindowComponent'](tab.component);
} else {
console.warn(`Unable to find window component for type '${ tab.component }'`); // eslint-disable-line no-console
this.component[tab.component] = null;
}
}
return this.component[tab.component];
}
}
};

View File

@ -1,16 +1,24 @@
import { RouteConfig } from 'vue-router';
import { DSL as STORE_DSL } from '@shell/store/type-map';
import { IPlugin } from './types';
import coreStore, { coreStoreModule, coreStoreState } from '@/shell/plugins/dashboard-store';
import {
PluginRouteConfig, RegisterStore, UnregisterStore, CoreStoreSpecifics, CoreStoreConfig, OnNavToPackage, OnNavAwayFromPackage, OnLogOut
} from '@/shell/core/types';
export class Plugin implements IPlugin {
public id: string;
public name: string;
public types: any = {};
public i18n: { [key: string]: Function[] } = {};
public l10n: { [key: string]: Function[] } = {};
public locales: { locale: string, label: string}[] = [];
public products: Function[] = [];
public productNames: string[] = [];
public routes: { parent?: string, route: RouteConfig }[] = [];
public stores: { storeName: string, register: RegisterStore, unregister: UnregisterStore }[] = [];
public onEnter: OnNavToPackage = () => Promise.resolve();
public onLeave: OnNavAwayFromPackage = () => Promise.resolve();
public _onLogOut: OnLogOut = () => Promise.resolve();
// Plugin metadata (plugin package.json)
public _metadata: any = {};
@ -52,26 +60,117 @@ export class Plugin implements IPlugin {
this.locales.push({ locale, label });
}
addRoute(parentOrRoute: any, route?: any): void {
if (typeof (parentOrRoute) === 'string') {
this.routes.push({ parent: parentOrRoute as string, route });
} else {
this.routes.push({ route: parentOrRoute as RouteConfig });
}
addL10n(locale: string, fn: Function) {
this.register('l10n', locale, fn);
}
addRoutes(routes: PluginRouteConfig[] | RouteConfig[]) {
routes.forEach((r: PluginRouteConfig | RouteConfig) => {
if (Object.keys(r).includes('parent')) {
const pConfig = r as PluginRouteConfig;
if (pConfig.parent) {
this.addRoute(pConfig.parent, pConfig.route);
} else {
this.addRoute(pConfig.route);
}
} else {
this.addRoute(r as RouteConfig);
}
});
}
addRoute(parentOrRoute: RouteConfig | string, optionalRoute?: RouteConfig): void {
// Always add the pkg name to the route metadata
const hasParent = typeof (parentOrRoute) === 'string';
const parent: string | undefined = hasParent ? parentOrRoute as string : undefined;
const route: RouteConfig = hasParent ? optionalRoute as RouteConfig : parentOrRoute as RouteConfig;
route.meta = {
...route?.meta,
pkg: this.name,
};
this.routes.push({ parent, route });
}
addUninstallHook(hook: Function) {
this.uninstallHooks.push(hook);
}
register(type: string, name: string, fn: Function) {
// Accumulate i18n resources rather than replace
if (type === 'i18n') {
if (!this.i18n[name]) {
this.i18n[name] = [];
addStore(storeName: string, register: RegisterStore, unregister: UnregisterStore) {
this.stores.push({
storeName, register, unregister
});
}
addDashboardStore(storeName: string, storeSpecifics: CoreStoreSpecifics, config: CoreStoreConfig) {
this.stores.push({
storeName,
register: () => {
return coreStore(
this.storeFactory(storeSpecifics, config),
config,
);
},
unregister: (store: any) => {
store.unregisterModule(storeName);
}
});
}
private storeFactory(storeSpecifics: CoreStoreSpecifics, config: CoreStoreConfig) {
return {
...coreStoreModule,
state() {
return {
...coreStoreState(config.namespace, config.baseUrl, config.isClusterStore),
...storeSpecifics.state()
};
},
getters: {
...coreStoreModule.getters,
...storeSpecifics.getters
},
mutations: {
...coreStoreModule.mutations,
...storeSpecifics.mutations
},
actions: {
...coreStoreModule.actions,
...storeSpecifics.actions
},
};
}
public addNavHooks(
onEnter: OnNavToPackage = () => Promise.resolve(),
onLeave: OnNavAwayFromPackage = () => Promise.resolve(),
onLogOut: OnLogOut = () => Promise.resolve(),
): void {
this.onEnter = onEnter;
this.onLeave = onLeave;
this._onLogOut = onLogOut;
}
public async onLogOut(store: any) {
await Promise.all(this.stores.map((s: any) => store.dispatch(`${ s.storeName }/onLogout`)));
await this._onLogOut(store);
}
public register(type: string, name: string, fn: Function) {
// Accumulate l10n resources rather than replace
if (type === 'l10n') {
if (!this.l10n[name]) {
this.l10n[name] = [];
}
this.i18n[name].push(fn);
this.l10n[name].push(fn);
} else {
if (!this.types[type]) {
this.types[type] = {};

View File

@ -1,5 +1,5 @@
import { productsLoaded } from '@shell/store/type-map';
import { clearModelCache } from '@shell/plugins/steve/model-loader';
import { clearModelCache } from '@shell/plugins/dashboard-store/model-loader';
import { Plugin } from './plugin';
import { PluginRoutes } from './plugin-routes';
@ -156,6 +156,9 @@ export default function({
// Remove the plugin itself
store.dispatch('uiplugins/removePlugin', name);
// Unregister vuex stores
plugin.stores.forEach(pStore => pStore.unregister(store));
// Update last load since we removed a plugin
_lastLoaded = new Date().getTime();
},
@ -175,10 +178,10 @@ export default function({
});
});
// i18n
Object.keys(plugin.i18n).forEach((name) => {
plugin.i18n[name].forEach((fn) => {
this.register('i18n', name, fn);
// l10n
Object.keys(plugin.l10n).forEach((name) => {
plugin.l10n[name].forEach((fn) => {
this.register('l10n', name, fn);
});
});
@ -187,6 +190,9 @@ export default function({
this.loadProducts([plugin]);
}
// Register vuex stores
plugin.stores.forEach(pStore => pStore.register()(store));
// Locales
plugin.locales.forEach((localeObj) => {
store.dispatch('i18n/addLocale', localeObj);
@ -199,7 +205,7 @@ export default function({
/**
* Register 'something' that can be dynamically loaded - e.g. model, edit, create, list, i18n
* @param {String} type type of thing to register, e.g. 'edit'
* @param {String} name type of thing to register, e.g. 'edit'
* @param {String} name unique name of 'something'
* @param {Function} fn function that dynamically loads the module for the thing being registered
*/
register(type, name, fn) {
@ -207,8 +213,8 @@ export default function({
dynamic[type] = {};
}
// Accumulate i18n resources rather than replace
if (type === 'i18n') {
// Accumulate l10n resources rather than replace
if (type === 'l10n') {
if (!dynamic[type][name]) {
dynamic[type][name] = [];
}
@ -220,7 +226,7 @@ export default function({
},
unregister(type, name, fn) {
if (type === 'i18n') {
if (type === 'l10n') {
if (dynamic[type]?.[name]) {
const index = dynamic[type][name].find(func => func === fn);

View File

@ -15,6 +15,25 @@ export interface PackageMetadata {
// children: Route[];
// }
export type VuexStoreObject = { [key: string]: any }
export type CoreStoreSpecifics = { state: () => VuexStoreObject, getters: VuexStoreObject, mutations: VuexStoreObject, actions: VuexStoreObject }
export type CoreStoreConfig = { namespace: string, baseUrl?: string, modelBaseClass?: string, supportsStream?: boolean, isClusterStore?: boolean }
export type RegisterStore = () => (store: any) => void
export type UnregisterStore = (store: any) => void
export type PluginRouteConfig = {parent?: string, route: RouteConfig}
export type OnEnterLeavePackageConfig = {
clusterId: string,
product: string,
oldProduct: string,
isExt: string,
oldIsExt: string
}
export type OnNavToPackage = (store: any, config: OnEnterLeavePackageConfig) => Promise<void>;
export type OnNavAwayFromPackage = (store: any, config: OnEnterLeavePackageConfig) => Promise<void>;
export type OnLogOut = (store: any) => Promise<void>;
/**
* Interface for a Dashboard plugin
*/
@ -37,16 +56,59 @@ export interface IPlugin {
*/
metadata: PackageMetadata;
/**
* Add a module contains localisations for a specific locale
*/
addL10n(locale: string, fn: Function): void;
/**
* Add a route to the Vue Router
*/
addRoute(route: RouteConfig): void;
addRoute(parent: string, route: RouteConfig): void;
/**
* Add routes to the Vue Router
*/
addRoutes(routes: PluginRouteConfig[] | RouteConfig[]): void;
/**
* Add a hook to be called when the plugin is uninstalled
* @param hook Function to call when the plugin is uninstalled
*/
addUninstallHook(hook: Function): void;
/**
* Add a generic Vuex Store
*/
addStore(storeName: string, register: RegisterStore, unregister: UnregisterStore): void;
/**
* Add a dashboard Vuex store.
*
* This will contain the toolset (getters/mutations/actions/etc) required by the dashboard to support Dashboard components. Most of these
* will be automatically supplemented when the store is registered, others though will need to be provided to supply package specific
* functionality (see storeSpecifics). For instance a component may request to fetch all of a resource type which, via a number of generic
* actions, will eventually call a `request` action which will make the raw http request. This is a pkg specific feature so needs the
* `request` action needs to be supplied in the `storeSpecifics`
*/
addDashboardStore(storeName: string, storeSpecifics: CoreStoreSpecifics, config: CoreStoreConfig): void;
/**
* Add hooks that will execute when a user navigates
* - to a route owned by this package
* - from a route owned by this package
*/
addNavHooks(
onEnter?: OnNavToPackage,
onLeave?: OnNavAwayFromPackage,
onLogOut?: OnLogOut
): void;
/**
* Register 'something' that can be dynamically loaded - e.g. model, edit, create, list, i18n
* @param {String} type type of thing to register, e.g. 'edit'
* @param {String} name unique name of 'something'
* @param {Function} fn function that dynamically loads the module for the thing being registered
*/
register(type: string, name: string, fn: Function): void;
}

View File

@ -33,9 +33,6 @@
"@shell/store/*": [
"../../node_modules/@rancher/shell/store/*"
],
"@pkg/*": [
"../../pkg/*"
]
}
},
"include": [

View File

@ -4,7 +4,7 @@ import ResourcesSummary from '@shell/components/fleet/ResourcesSummary';
import ResourceTabs from '@shell/components/form/ResourceTabs';
import { COUNT, WORKLOAD_TYPES } from '@shell/config/types';
import { WORKLOAD_SCHEMA } from '@shell/config/schema';
import { getStatesByType } from '@shell/plugins/steve/resource-class';
import { getStatesByType } from '@shell/plugins/dashboard-store/resource-class';
import MoveModal from '@shell/components/MoveModal';
import Tab from '@shell/components/Tabbed/Tab';
import ResourceTable from '@shell/components/ResourceTable';

View File

@ -3,7 +3,7 @@ import CreateEditView from '@shell/mixins/create-edit-view';
import {
STATE, NAME, NODE, POD_IMAGES, POD_RESTARTS
} from '@shell/config/table-headers';
import { POD, WORKLOAD_TYPES } from '@shell/config/types';
import { POD, WORKLOAD_TYPES, SCALABLE_WORKLOAD_TYPES } from '@shell/config/types';
import SortableTable from '@shell/components/SortableTable';
import Tab from '@shell/components/Tabbed/Tab';
import Loading from '@shell/components/Loading';
@ -15,7 +15,6 @@ import V1WorkloadMetrics from '@shell/mixins/v1-workload-metrics';
import { mapGetters } from 'vuex';
import { allDashboardsExist } from '@shell/utils/grafana';
import PlusMinus from '@shell/components/form/PlusMinus';
import { SCALABLE_WORKLOAD_TYPES } from '@shell/config/types';
const SCALABLE_TYPES = Object.values(SCALABLE_WORKLOAD_TYPES);
const WORKLOAD_METRICS_DETAIL_URL = '/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/d/rancher-workload-pods-1/rancher-workload-pods?orgId=1';

View File

@ -19,7 +19,6 @@ import { randomStr } from '@shell/utils/string';
import { RunStrategys } from '@shell/config/harvester-map';
import { _CONFIG, _EDIT, _VIEW } from '@shell/config/query-params';
import { HCI as HCI_ANNOTATIONS } from '@shell/config/labels-annotations';
import { cleanForNew } from '@shell/plugins/steve/normalize';
import VM_MIXIN from '@shell/mixins/harvester-vm';
import CreateEditView from '@shell/mixins/create-edit-view';
@ -54,7 +53,7 @@ export default {
data() {
if (this.mode === _EDIT) {
this.value = cleanForNew(this.value);
this.value = this.value.cleanForNew();
}
const templateId = this.value.templateId || this.$route.query.templateId;
@ -165,7 +164,7 @@ export default {
template.save();
}
cleanForNew(this.value);
this.value.cleanForNew();
this.customName = randomStr(10);
this.$set(this.value.metadata, 'annotations', {
...this.value.metadata.annotations,

View File

@ -27,7 +27,6 @@ import { HCI } from '@shell/config/types';
import { RunStrategys } from '@shell/config/harvester-map';
import { saferDump } from '@shell/utils/create-yaml';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { cleanForNew } from '@shell/plugins/steve/normalize';
import { HCI as HCI_ANNOTATIONS } from '@shell/config/labels-annotations';
import { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
@ -277,7 +276,7 @@ export default {
this.$set(this, 'spec', cloneValue.spec);
const suffix = i < 10 ? `0${ i }` : i;
cleanForNew(this.value);
this.value.cleanForNew();
this.value.metadata.name = `${ this.namePrefix }${ join }${ suffix }`;
this.$set(this.value.spec.template.spec, 'hostname', `${ baseHostname }${ join }${ suffix }`);
this.secretName = '';

View File

@ -270,7 +270,7 @@ export default {
set(this.value.spec.rkeConfig, 'upgradeStrategy', {
controlPlaneConcurrency: '1',
controlPlaneDrainOptions: {},
workerConcurrency: '10%',
workerConcurrency: '1',
workerDrainOptions: {},
});
}

View File

@ -14,7 +14,7 @@ import {
EVENT, METRIC, NODE, HCI, SERVICE, PVC, LONGHORN, POD, COUNT, NETWORK_ATTACHMENT
} from '@shell/config/types';
import ResourceSummary, { resourceCounts, colorToCountName } from '@shell/components/ResourceSummary';
import { colorForState } from '@shell/plugins/steve/resource-class';
import { colorForState } from '@shell/plugins/dashboard-store/resource-class';
import HardwareResourceGauge from '@shell/components/HardwareResourceGauge';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';

View File

@ -5,7 +5,7 @@ import {
} from '@shell/config/query-params';
import { SETTING } from '@shell/config/settings';
import { MANAGEMENT, NORMAN } from '@shell/config/types';
import { _ALL_IF_AUTHED } from '@shell/plugins/steve/actions';
import { _ALL_IF_AUTHED } from '@shell/plugins/dashboard-store/actions';
import { applyProducts } from '@shell/store/type-map';
import { findBy } from '@shell/utils/array';
import { ClusterNotFoundError } from '@shell/utils/error';
@ -14,6 +14,16 @@ import { AFTER_LOGIN_ROUTE } from '@shell/store/prefs';
import { NAME as VIRTUAL } from '@shell/config/product/harvester';
import { BACK_TO } from '@shell/config/local-storage';
const getPackageFromRoute = (route) => {
if (!route?.meta) {
return;
}
// Sometimes meta is an array... sometimes not
const arraySafe = Array.isArray(route.meta) ? route.meta : [route.meta];
return arraySafe.find(m => !!m.pkg)?.pkg;
};
let beforeEachSetup = false;
function setProduct(store, to) {
@ -248,56 +258,88 @@ export default async function({
try {
let clusterId = get(route, 'params.cluster');
const product = get(route, 'params.product');
const pkg = getPackageFromRoute(route);
const product = route?.params?.product;
const oldPkg = getPackageFromRoute(from);
const oldProduct = from?.params?.product;
// Leave an old pkg where we weren't before?
const oldPkgPlugin = oldPkg ? Object.values($plugin.getPlugins()).find(p => p.name === oldPkg) : null;
if (oldPkg && oldPkg !== pkg ) {
// Execute anything optional the plugin wants to. For example resetting it's store to remove data
await oldPkgPlugin.onLeave(store, {
clusterId,
product,
oldProduct,
oldIsExt: !!oldPkg
});
}
// Sometimes this needs to happen before or alongside other things... but is always needed
const always = [
store.dispatch('loadManagement')
];
// Entering a new package where we weren't before?
const newPkgPlugin = pkg ? Object.values($plugin.getPlugins()).find(p => p.name === pkg) : null;
// Note - We can't block on oldPkg !== newPkg because on a fresh load the `from` route equals the to `route`
if (pkg && (oldPkg !== pkg || from.fullPath === route.fullPath)) {
// Execute mandatory store actions
await Promise.all(always);
// Execute anything optional the plugin wants to
await newPkgPlugin.onEnter(store, {
clusterId,
product,
oldProduct,
oldIsExt: !!oldPkg
});
}
if (product === VIRTUAL || route.name === `c-cluster-${ VIRTUAL }` || route.name?.startsWith(`c-cluster-${ VIRTUAL }-`)) {
const res = [
store.dispatch('loadManagement'),
...always,
store.dispatch('loadVirtual', {
id: clusterId,
oldProduct,
}),
];
await Promise.all(res);
} else if ( clusterId ) {
// Run them in parallel
const res = [
store.dispatch('loadManagement'),
store.dispatch('loadCluster', {
id: clusterId,
product,
oldProduct,
}),
];
await Promise.all(res);
} else {
await store.dispatch('loadManagement');
clusterId = store.getters['defaultClusterId']; // This needs the cluster list, so no parallel
const isSingleVirtualCluster = store.getters['isSingleVirtualCluster'];
if (isSingleVirtualCluster) {
const value = {
name: 'c-cluster-product',
params: {
cluster: clusterId,
product: VIRTUAL,
},
};
await store.dispatch('prefs/set', {
key: AFTER_LOGIN_ROUTE,
value,
});
} else if ( clusterId) {
await store.dispatch('loadCluster', {
id: clusterId,
product,
// Always run loadCluster, it handles 'unload' as well
// Run them in parallel
await Promise.all([
...always,
store.dispatch('loadCluster', {
id: clusterId,
oldPkg: oldPkgPlugin,
newPkg: newPkgPlugin,
oldProduct,
});
})]);
if (!clusterId) {
clusterId = store.getters['defaultClusterId']; // This needs the cluster list, so no parallel
const isSingleVirtualCluster = store.getters['isSingleVirtualCluster'];
if (isSingleVirtualCluster) {
const value = {
name: 'c-cluster-product',
params: {
cluster: clusterId,
product: VIRTUAL,
},
};
await store.dispatch('prefs/set', {
key: AFTER_LOGIN_ROUTE,
value,
});
}
}
}
} catch (e) {

View File

@ -4,7 +4,7 @@ import { exceptionToErrorsArray } from '@shell/utils/error';
import ChildHook, { BEFORE_SAVE_HOOKS, AFTER_SAVE_HOOKS } from '@shell/mixins/child-hook';
import { clear } from '@shell/utils/array';
import { DEFAULT_WORKSPACE } from '@shell/config/types';
import { handleConflict } from '@shell/plugins/steve/normalize';
import { handleConflict } from '@shell/plugins/dashboard-store/normalize';
export default {

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