dashboard/shell/components/ResourceDetail/index.vue

562 lines
15 KiB
Vue

<script>
import CreateEditView from '@shell/mixins/create-edit-view/impl';
import Loading from '@shell/components/Loading';
import ResourceYaml from '@shell/components/ResourceYaml';
import {
_VIEW, _EDIT, _CLONE, _IMPORT, _STAGE, _CREATE,
AS, _YAML, _DETAIL, _CONFIG, PREVIEW, MODE,
} from '@shell/config/query-params';
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, diff } from '@shell/utils/object';
import IconMessage from '@shell/components/IconMessage';
import { stringify } from '@shell/utils/error';
import { Banner } from '@components/Banner';
function modeFor(route) {
if ( route.query?.mode === _IMPORT ) {
return _IMPORT;
}
if ( route.params?.id ) {
return route.query.mode || _VIEW;
} else {
return _CREATE;
}
}
async function getYaml(store, model) {
let yaml;
const opt = { headers: { accept: 'application/yaml' } };
if ( model.hasLink('view') ) {
yaml = (await model.followLink('view', opt)).data;
}
return model.cleanForDownload(yaml);
}
export default {
emits: ['input'],
components: {
Loading,
DetailTop,
ResourceYaml,
Masthead,
IconMessage,
Banner
},
mixins: [CreateEditView],
props: {
storeOverride: {
type: String,
default: null,
},
resourceOverride: {
type: String,
default: null,
},
parentRouteOverride: {
type: String,
default: null,
},
flexContent: {
type: Boolean,
default: false,
},
/**
* Inherited global identifier prefix for tests
* Define a term based on the parent component to avoid conflicts on multiple components
*/
componentTestid: {
type: String,
default: 'resource-details'
},
errorsMap: {
type: Object,
default: null
},
},
async fetch() {
const store = this.$store;
const route = this.$route;
const params = route.params;
let resourceType = this.resourceOverride || params.resource;
const inStore = this.storeOverride || store.getters['currentStore'](resourceType);
const realMode = this.realMode;
// eslint-disable-next-line prefer-const
let { namespace, id } = params;
// There are 6 "real" modes that can be put into the query string
// These are mapped down to the 3 regular page "mode"s that create-edit-view components
// know about: view, edit, create (stage, import and clone become "create")
const mode = ([_CLONE, _IMPORT, _STAGE].includes(realMode) ? _CREATE : realMode);
const hasCustomDetail = store.getters['type-map/hasCustomDetail'](resourceType, id);
const hasCustomEdit = store.getters['type-map/hasCustomEdit'](resourceType, id);
const schemas = store.getters[`${ inStore }/all`](SCHEMA);
// As determines what component will be rendered
const requested = route.query[AS];
let as;
let notFound = false;
if ( mode === _VIEW && hasCustomDetail && (!requested || requested === _DETAIL) ) {
as = _DETAIL;
} else if ( hasCustomEdit && (!requested || requested === _CONFIG) ) {
as = _CONFIG;
} else {
as = _YAML;
}
this.as = as;
const options = store.getters[`type-map/optionsFor`](resourceType);
this.showMasthead = [_CREATE, _EDIT].includes(mode) ? options.resourceEditMasthead : true;
const canViewYaml = options.canYaml;
if ( options.resource ) {
resourceType = options.resource;
}
const schema = store.getters[`${ inStore }/schemaFor`](resourceType);
let model, initialModel, liveModel, yaml;
if ( realMode === _CREATE || realMode === _IMPORT ) {
if ( !namespace ) {
namespace = store.getters['defaultNamespace'];
}
const data = { type: resourceType };
if ( schema?.attributes?.namespaced ) {
data.metadata = { namespace };
}
liveModel = await store.dispatch(`${ inStore }/create`, data);
initialModel = await store.dispatch(`${ inStore }/clone`, { resource: liveModel });
model = await store.dispatch(`${ inStore }/clone`, { resource: liveModel });
if (model.forceYaml === true) {
as = _YAML;
this.as = as;
}
if ( as === _YAML ) {
if (schema?.fetchResourceFields) {
// fetch resourceFields for createYaml
await schema.fetchResourceFields();
}
yaml = createYaml(schemas, resourceType, data);
}
} else {
let fqid = id;
if ( schema.attributes?.namespaced && namespace ) {
fqid = `${ namespace }/${ fqid }`;
}
try {
liveModel = await store.dispatch(`${ inStore }/find`, {
type: resourceType,
id: fqid,
opt: { watch: true }
});
} catch (e) {
if (e.status === 404 || e.status === 403) {
store.dispatch('loadingError', new Error(this.t('nav.failWhale.resourceIdNotFound', { resource: resourceType, fqid }, true)));
}
console.debug(`Could not find '${ resourceType }' with id '${ id }''`, e); // eslint-disable-line no-console
liveModel = {};
notFound = fqid;
}
try {
if (realMode === _VIEW) {
model = liveModel;
} else {
model = await store.dispatch(`${ inStore }/clone`, { resource: liveModel });
}
initialModel = await store.dispatch(`${ inStore }/clone`, { resource: liveModel });
if ( as === _YAML ) {
yaml = await getYaml(this.$store, liveModel);
}
} catch (e) {
this.errors.push(e);
}
if ( as === _YAML ) {
try {
yaml = await getYaml(this.$store, liveModel);
} catch (e) {
this.errors.push(e);
}
}
if ( [_CLONE, _IMPORT, _STAGE].includes(realMode) ) {
model.cleanForNew();
yaml = model.cleanYaml(yaml, realMode);
}
}
// Ensure common properties exists
try {
model = await store.dispatch(`${ inStore }/cleanForDetail`, model);
} catch (e) {
this.errors.push(e);
}
const out = {
hasCustomDetail,
hasCustomEdit,
canViewYaml,
resourceType,
as,
yaml,
initialModel,
liveModel,
mode,
value: model,
notFound,
};
for ( const key in out ) {
this[key] = out[key];
}
if ( this.mode === _CREATE ) {
this.value.applyDefaults(this, realMode);
}
},
data() {
return {
resourceSubtype: null,
// Set by fetch
hasCustomDetail: null,
hasCustomEdit: null,
resourceType: null,
asYaml: null,
yaml: null,
liveModel: null,
initialModel: null,
mode: null,
as: null,
value: null,
model: null,
notFound: null,
canViewYaml: null,
errors: []
};
},
computed: {
realMode() {
// There are 5 "real" modes that you can start in: view, edit, create, stage, clone
const realMode = modeFor(this.$route);
return realMode;
},
isView() {
return this.mode === _VIEW;
},
isYaml() {
return this.as === _YAML;
},
isDetail() {
return this.as === _DETAIL;
},
offerPreview() {
return this.as === _YAML && [_EDIT, _CLONE, _IMPORT, _STAGE].includes(this.mode);
},
showComponent() {
switch ( this.as ) {
case _DETAIL: return this.detailComponent;
case _CONFIG: return this.editComponent;
}
return null;
},
hasErrors() {
return this.errors?.length && Array.isArray(this.errors);
},
mappedErrors() {
return !this.errors ? {} : this.errorsMap || this.errors.reduce((acc, error) => ({
...acc,
[error]: {
message: error?.data?.message || error,
icon: null
}
}), {});
},
isFullPageOverride() {
return this.isView && this.value.fullDetailPageOverride;
}
},
watch: {
'$route'(current, prev) {
if (current.name !== prev.name) {
return;
}
const neu = clone(current.query);
const old = clone(prev.query);
delete neu[PREVIEW];
delete old[PREVIEW];
if ( !this.isView ) {
delete neu[AS];
delete old[AS];
}
const queryDiff = Object.keys(diff(neu, old));
if (queryDiff.includes(MODE) || queryDiff.includes(AS)) {
this.$fetch();
}
},
// Auto refresh YAML when the model changes
async 'value.metadata.resourceVersion'(a, b) {
if ( this.mode === _VIEW && this.as === _YAML && a && b && a !== b) {
this.yaml = await getYaml(this.$store, this.liveModel);
}
}
},
created() {
this.configureResource();
},
methods: {
stringify,
setSubtype(subtype) {
this.resourceSubtype = subtype;
},
keyAction(act) {
const m = this.liveModel;
if ( m?.[act] ) {
m[act]();
}
},
closeError(index) {
this.errors = this.errors.filter((_, i) => i !== index);
},
onYamlError(err) {
this.errors = [];
const errors = Array.isArray(err) ? err : [err];
errors.forEach((e) => {
if (this.errors.indexOf(e) === -1) {
this.errors.push(e);
}
});
},
/**
* Initializes the resource components based on the provided user and
* resource override.
*
* Configures the detail and edit components for a resource based on the
* user's ID and the specified resource.
*
* @param {Object} user - The user object containing user-specific
* information.
* @param {string|null} resourceOverride - An optional resource override
* string. If not provided, the method will use the default resource from
* the route parameters or the instance's resourceOverride property.
*/
configureResource(userId = '', resourceOverride = null) {
const id = userId || this.$route.params.id;
const resource = resourceOverride || this.resourceOverride || this.$route.params.resource;
const options = this.$store.getters[`type-map/optionsFor`](resource);
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);
},
/**
* Sets the mode and initializes the resource components.
*
* This method sets the mode of the component and configures the resource
* components based on the provided user and resource.
*
* @param {Object} payload - An object containing the mode, user, and
* resource properties.
* @param {string} payload.mode - The mode to set.
* @param {Object} payload.user - The user object containing user-specific
* information.
* @param {string} payload.resource - The resource string to use for
* initialization.
*/
setMode({ mode, userId, resource }) {
this.mode = mode;
this.value.id = userId;
this.configureResource(userId, resource);
}
}
};
</script>
<template>
<Loading v-if="$fetchState.pending || notFound" />
<component
:is="showComponent"
v-else-if="isFullPageOverride"
v-model:value="value"
v-ui-context="{ icon: 'icon-folder', value: value.name, tag: value.kind?.toLowerCase(), description: value.kind }"
v-bind="$data"
:done-params="doneParams"
:done-route="doneRoute"
:mode="mode"
:initial-value="initialModel"
:live-value="liveModel"
:real-mode="realMode"
:class="{'flex-content': flexContent}"
:resource-errors="errors"
@update:value="$emit('input', $event)"
@update:mode="setMode"
@set-subtype="setSubtype"
/>
<div v-else>
<Masthead
v-if="showMasthead"
v-ui-context="{ icon: 'icon-folder', value: liveModel.name, tag: liveModel.kind?.toLowerCase(), description: liveModel.kind }"
:resource="resourceType"
:value="liveModel"
:mode="mode"
:real-mode="realMode"
:as="as"
:has-detail="hasCustomDetail"
:has-edit="hasCustomEdit"
:can-view-yaml="canViewYaml"
:resource-subtype="resourceSubtype"
:parent-route-override="parentRouteOverride"
:store-override="storeOverride"
>
<DetailTop
v-if="isView && isDetail"
:value="liveModel"
/>
</Masthead>
<div
v-if="hasErrors"
id="cru-errors"
class="cru__errors"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:data-testid="`error-banner${i}`"
:label="stringify(mappedErrors[err].message)"
:icon="mappedErrors[err].icon"
:closable="true"
@close="closeError(i)"
/>
</div>
<ResourceYaml
v-if="isYaml"
ref="resourceyaml"
:value="value"
:mode="mode"
:yaml="yaml"
:offer-preview="offerPreview"
:done-route="doneRoute"
:done-override="value ? value.doneOverride : null"
:show-errors="false"
@update:value="$emit('input', $event)"
@error="onYamlError"
/>
<component
:is="showComponent"
v-else
ref="comp"
v-model:value="value"
v-ui-context="{ icon: 'icon-folder', value: value.name, tag: value.kind?.toLowerCase(), description: value.kind }"
v-bind="$data"
:done-params="doneParams"
:done-route="doneRoute"
:mode="mode"
:initial-value="initialModel"
:live-value="liveModel"
:real-mode="realMode"
:class="{'flex-content': flexContent}"
@update:value="$emit('input', $event)"
@update:mode="setMode"
@set-subtype="setSubtype"
/>
<button
v-if="isView"
v-shortkey.once="['shift','d']"
:data-testid="componentTestid + '-detail'"
class="hide"
@shortkey="keyAction('goToDetail')"
/>
<button
v-if="isView"
v-shortkey.once="['shift','c']"
:data-testid="componentTestid + '-config'"
class="hide"
@shortkey="keyAction('goToViewConfig')"
/>
<button
v-if="isView"
v-shortkey.once="['shift','y']"
:data-testid="componentTestid + '-yaml'"
class="hide"
@shortkey="keyAction('goToViewYaml')"
/>
<button
v-if="isView"
v-shortkey.once="['shift','e']"
:data-testid="componentTestid + '-edit'"
class="hide"
@shortkey="keyAction('goToEdit')"
/>
</div>
</template>
<style lang='scss' scoped>
.flex-content {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.cru__errors {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--header-bg);
}
</style>