Merge pull request #1800 from westlywright/2-oh

2 oh
This commit is contained in:
Vincent Fiduccia 2018-04-06 18:37:41 -07:00 committed by GitHub
commit b6069d80bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 804 additions and 390 deletions

View File

@ -0,0 +1,206 @@
import Controller from '@ember/controller';
import { get, computed } from '@ember/object';
import {
searchFields as containerSearchFields
} from 'shared/components/pod-dots/component';
const podsHeaders = [
{
name: 'expand',
sort: false,
searchField: null,
width: 30
},
{
name: 'state',
sort: ['sortState', 'displayName'],
searchField: 'displayState',
translationKey: 'generic.state',
width: 120
},
{
name: 'name',
sort: ['sortName', 'id'],
searchField: 'displayName',
translationKey: 'generic.name',
},
{
name: 'image',
sort: ['image', 'displayName'],
searchField: 'image',
translationKey: 'generic.image',
},
{
name: 'scale',
sort: ['scale:desc', 'isGlobalScale:desc', 'displayName'],
searchField: null,
translationKey: 'stacksPage.table.scale',
classNames: 'text-center',
width: 100
},
]
const ingressHeaders = [
{
name: 'state',
sort: ['sortState','displayName'],
searchField: 'displayState',
translationKey: 'generic.state',
width: 120
},
{
name: 'name',
sort: ['sortName','id'],
searchField: 'displayName',
translationKey: 'generic.name',
},
{
name: 'created',
sort: ['created','id'],
classNames: 'text-right pr-20',
searchField: 'created',
translationKey: 'generic.created',
},
]
const servicesHeaders = [
{
name: 'state',
sort: ['stack.isDefault:desc','stack.displayName','sortState','displayName'],
searchField: 'displayState',
translationKey: 'generic.state',
width: 120
},
{
name: 'name',
sort: ['stack.isDefault:desc','stack.displayName','displayName','id'],
searchField: 'displayName',
translationKey: 'generic.name',
},
{
name: 'displayType',
sort: ['displayType','displayName','id'],
searchField: 'displayType',
translationKey: 'generic.type',
width: 150,
},
{
name: 'target',
sort: false,
searchField: 'displayTargets',
translationKey: 'dnsPage.table.target',
},
]
const volumesHeaders = [
// {
// name: 'expand',
// sort: false,
// searchField: null,
// width: 30
// },
{
name: 'state',
sort: ['sortState','displayName'],
searchField: 'displayState',
translationKey: 'generic.state',
width: 120
},
{
name: 'name',
sort: ['displayName','id'],
searchField: 'displayName',
translationKey: 'volumesPage.claimName.label',
},
{
name: 'size',
sort: ['sizeBytes'],
search: ['sizeBytes','displaySize'],
translationKey: 'generic.size',
width: 120
},
{
name: 'volume',
sort: ['volume.displayName','displayName','id'],
translationKey: 'volumesPage.volume.label',
searchField: null,
},
{
name: 'storageClass',
sort: ['storageClass.displayName','displayName','id'],
translationKey: 'volumesPage.storageClass.label',
searchField: null,
},
]
const secretsHeaders = [
{
name: 'state',
sort: ['sortState','name','id'],
type: 'string',
searchField: 'displayState',
translationKey: 'generic.state',
width: 125,
},
{
name: 'name',
sort: ['name','id'],
translationKey: 'generic.name',
},
{
name: 'namespace',
translationKey: 'generic.namespace',
searchField: 'namespace.displayName',
sort: ['namespace.displayName','name','id'],
},
{
name: 'keys',
translationKey: 'secretsPage.table.keys',
searchField: 'keys',
sort: ['firstKey','name','id'],
},
{
name: 'created',
translationKey: 'generic.created',
sort: ['created:desc','name','id'],
searchField: false,
type: 'string',
width: 150,
},
]
export default Controller.extend({
// TODO =- expand logic?
expandedInstances: [],
ingressHeaders: ingressHeaders,
servicesHeaders: servicesHeaders,
volumesHeaders: volumesHeaders,
secretsHeaders: secretsHeaders,
ingressSearchText: '',
secretsSearchText: '',
podsHeaders: podsHeaders,
podsSearchText: '',
servicesSearchText: '',
volumesSearchText: '',
sortBy: 'name',
extraSearchFields: ['id:prefix', 'displayIp:ip'],
extraSearchSubFields: containerSearchFields,
actions: {
toggleExpand() {
// ???
},
},
stdOut: computed('model.app.stdOut', function() {
return get(this, 'model.app.status.stdOutput');
}),
stderr: computed('model.app.stdErr', function() {
return get(this, 'model.app.status.stdError');
}),
workloadsAndPods: computed('model.app.workloads', 'model.app.pods', function() {
let out = [];
out = this.get('model.app.pods').filter(obj => !obj.get('workloadId'));
out.pushObjects(this.get('model.app.workloads').slice());
return out;
}),
});

View File

@ -0,0 +1,16 @@
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default Route.extend({
catalog: service(),
store: service(),
model(params) {
const store = get(this, 'store');
return hash({
app: store.find('app', get(params, 'app_id')),
});
},
});

View File

@ -0,0 +1,243 @@
<section class="header clearfix">
<div class="pull-left">
<h1 class="vertical-middle">
{{t 'appDetailPage.header' appName=model.app.displayName}}
</h1>
</div>
<div class="vertical-middle"></div>
<div class="right-buttons">
{{badge-state model=model.app}}
{{action-menu model=model.app showPrimary=false classNames="ml-10 pull-right" size="sm"}}
</div>
</section>
<section>
{{#accordion-list as |al expandFn|}}
{{#accordion-list-item
title=(t 'appDetailPage.output.title')
detail=(t 'appDetailPage.output.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
<pre class="log-body" style="margin:0; font-size: 80%; color: whitesmoke;">{{stdOut}}</pre>
{{/accordion-list-item}}
{{#accordion-list-item
title=(t 'appDetailPage.workloads.title')
detail=(t 'appDetailPage.workloads.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
{{#sortable-table
tableClassNames="double-rows"
classNames="grid sortable-table"
body=workloadsAndPods
searchText=podsSearchText
sortBy=sortBy
bulkActions=true
subRows=true
pagingLabel="pagination.workload"
subSearchField="pods"
extraSearchFields=extraSearchFields
extraSearchSubFields=extraSearchSubFields
headers=podsHeaders as |sortable kind inst dt|}}
{{#if (eq kind "row")}}
{{#if (eq inst.type "pod")}}
{{pod-row
model=inst
dt=dt
showNode=true
expandPlaceholder=true
scalePlaceholder=true
fullColspan=sortable.fullColspan
toggle=(action "toggleExpand" inst.id)
expanded=(array-includes expandedInstances inst.id)
}}
{{else}}
{{workload-row
model=inst
toggle=(action "toggleExpand" inst.id)
expanded=(array-includes expandedInstances inst.id)
searchText=searchText
subMatches=sortable.subMatches
fullColspan=sortable.fullColspan
dt=dt
}}
{{/if}}
{{else if (eq kind "nomatch")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'containersPage.table.noMatch'}}</td></tr>
{{else if (eq kind "norows")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'appDetailPage.workloads.nodata'}}</td></tr>
{{/if}}
{{/sortable-table}}
{{!--
--}}
{{/accordion-list-item}}
{{#accordion-list-item
title=(t 'appDetailPage.ingress.title')
detail=(t 'appDetailPage.ingress.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
<section class="pl-0 pr-0">
{{#sortable-table
classNames="grid sortable-table"
body=model.app.ingress
searchText=ingressSearchText
sortBy=sortBy
bulkActions=true
pagingLabel="pagination.ingress"
subSearchField="instances"
headers=ingressHeaders as |sortable kind inst dt|
}}
{{#if (eq kind "row")}}
<tr class="main-row">
<td class="row-check" valign="middle" style="padding-top: 2px;">
{{check-box nodeId=inst.id}}
</td>
<td data-title="{{dt.state}}" class="state">
{{badge-state model=inst}}
</td>
<td data-title="{{dt.name}}" class="clip">
<a href="{{href-to "ingress" inst.id}}">{{inst.displayName}}</a>
</td>
<td data-title="{{dt.created}}" class="text-right pr-20">
{{date-calendar inst.created}}
</td>
<td data-title="{{dt.actions}}" class="actions">
{{action-menu model=inst}}
</td>
</tr>
{{else if (eq kind "nomatch")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'ingressPage.table.noMatch'}}</td></tr>
{{else if (eq kind "norows")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'appDetailPage.ingress.nodata'}}</td></tr>
{{/if}}
{{/sortable-table}}
</section>
{{/accordion-list-item}}
{{#accordion-list-item
title=(t 'appDetailPage.services.title')
detail=(t 'appDetailPage.services.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
{{#sortable-table
classNames="grid sortable-table"
body=model.app.services
searchText=servicesSearchText
sortBy=sortBy
bulkActions=true
pagingLabel="pagination.dnsRecord"
headers=servicesHeaders as |sortable kind inst dt|
}}
{{#if (eq kind "row")}}
{{dns-row
model=inst
searchText=searchText
fullColspan=sortable.fullColspan
dt=dt
}}
{{else if (eq kind "nomatch")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted pt-20 pb-20">{{t 'dnsPage.noMatch'}}</td></tr>
{{else if (eq kind "norows")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'appDetailPage.services.nodata'}}</td></tr>
{{/if}}
{{/sortable-table}}
{{/accordion-list-item}}
{{#accordion-list-item
title=(t 'appDetailPage.volumes.title')
detail=(t 'appDetailPage.volumes.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
<section class="pl-0 pr-0">
{{#sortable-table
classNames="grid sortable-table"
body=model.app.volumes
searchText=volumesSearchText
sortBy=sortBy
bulkActions=true
pagingLabel="pagination.volume"
headers=volumesHeaders as |sortable kind obj dt|
}}
{{#if (eq kind "row")}}
<tr class="main-row">
<td class="row-check" valign="middle" style="padding-top: 2px;">
{{check-box nodeId=obj.id}}
</td>
<td data-title="{{dt.state}}">
{{badge-state model=obj}}
</td>
<td data-title="{{dt.name}}">
<a href="{{href-to "volumes.detail" obj.id}}">{{obj.displayName}}</a>
</td>
<td data-title="{{dt.size}}">
{{obj.displaySize}}
</td>
<td data-title="{{dt.volume}}">
{{#if obj.persistentVolume}}
<a href="{{href-to "authenticated.cluster.storage.persistent-volumes.detail" scope.currentCluster.id obj.persistentVolume.id}}">
{{obj.persistentVolume.displayName}}
</a>
{{else}}
<span class="text-muted">&ndash;</span>
{{/if}}
</td>
<td data-title="{{dt.storageClass}}">
{{#if obj.storageClass}}
<a href="{{href-to "authenticated.cluster.storage.classes.detail" scope.currentCluster.id obj.storageClass.id}}">
{{obj.storageClass.displayName}}
</a>
{{else}}
<span class="text-muted">&ndash;</span>
{{/if}}
</td>
<td data-title="{{dt.actions}}" class="actions">
{{action-menu model=obj}}
</td>
</tr>
{{else if (eq kind "nomatch")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'volumesPage.noMatch'}}</td></tr>
{{else if (eq kind "norows")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'appDetailPage.volumes.nodata'}}</td></tr>
{{/if}}
{{/sortable-table}}
</section>
{{/accordion-list-item}}
{{#accordion-list-item
title=(t 'appDetailPage.secrets.title')
detail=(t 'appDetailPage.secrets.detail')
expandAll=al.expandAll
expand=(action expandFn)
expandOnInit=true
}}
{{#sortable-table
classNames="grid sortable-table"
body=model.app.secrets
sortBy=sortBy
bulkActions=true
searchText=secretsSearchText
headers=secretsHeaders as |sortable kind row dt|
}}
{{#if (eq kind "row")}}
{{secret-row model=row dt=dt}}
{{else if (eq kind "nomatch")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted pt-20 pb-20">{{t 'secretsPage.index.noMatch'}}</td></tr>
{{else if (eq kind "norows")}}
<tr><td colspan="{{sortable.fullColspan}}" class="text-center text-muted lacsso pt-20 pb-20">{{t 'appDetailPage.secrets.nodata'}}</td></tr>
{{/if}}
{{/sortable-table}}
{{/accordion-list-item}}
{{/accordion-list}}
</section>

View File

@ -1,14 +1,27 @@
import Resource from 'ember-api-store/models/resource';
import { hasMany } from 'ember-api-store/utils/denormalize';
import { hasMany, reference } from 'ember-api-store/utils/denormalize';
import { computed, get } from '@ember/object';
import { parseHelmExternalId } from 'ui/utils/parse-externalid';
import StateCounts from 'ui/mixins/state-counts';
import { inject as service } from '@ember/service';
const App = Resource.extend(StateCounts, {
catalog: service(),
router: service(),
pods: hasMany('installNamespace', 'pod', 'namespaceId'),
catalog: service(),
router: service(),
// pods: hasMany('id', 'pod', 'appId'),
// services: hasMany('id', 'service', 'appId'),
// workloads: hasMany('id', 'workload', 'appId'),
// secrets: hasMany('id', 'secret', 'appId'),
// ingress: hasMany('id', 'ingress', 'appId'),
// volumes: hasMany('id', 'persistentVolumeClaim', 'appId'),
pods: hasMany('installNamespace', 'pod', 'namespaceId'),
services: hasMany('installNamespace', 'service', 'namespaceId'),
workloads: hasMany('installNamespace', 'workload', 'namespaceId'),
secrets: hasMany('installNamespace', 'secret', 'namespaceId'),
ingress: hasMany('installNamespace', 'ingress', 'namespaceId'),
volumes: hasMany('installNamespace', 'persistentVolumeClaim', 'namespaceId'),
namespace: reference('namespaceId', 'namespace', 'clusterStore'),
//workloads on pod
init() {
this._super(...arguments);

View File

@ -150,11 +150,13 @@ Router.map(function() {
// Catalog
this.route('apps-tab', {path: '/apps', resetNamespace: true}, function() {
this.route('index', {path: '/'});
this.route('detail', {path: '/:app_id'});
this.route('catalog-tab', {path: '/catalog', resetNamespace: true}, function() {
this.route('index', {path: '/'});
this.route('launch', {path: '/:template'});
});
});
// Resources

View File

@ -4,71 +4,71 @@ import Controller from '@ember/controller';
import Errors from 'ui/utils/errors';
import C from 'ui/utils/constants';
import { alias } from '@ember/object/computed';
import { computed, observer } from '@ember/object';
import { get, set, computed, observer } from '@ember/object';
var PLAIN_PORT = 389;
var TLS_PORT = 636;
export default Controller.extend({
access: service(),
settings: service(),
access: service(),
settings: service(),
confirmDisable: false,
errors: null,
testing: false,
errors: null,
testing: false,
providerName: 'ldap.providerName.ad',
userType: C.PROJECT.TYPE_LDAP_USER,
groupType: C.PROJECT.TYPE_LDAP_GROUP,
providerName: 'ldap.providerName.ad',
userType: C.PROJECT.TYPE_LDAP_USER,
groupType: C.PROJECT.TYPE_LDAP_GROUP,
addUserInput: '',
addOrgInput: '',
addUserInput: '',
addOrgInput: '',
username: '',
password: '',
isEnabled: alias('model.activeDirectory.enabled'),
adConfig: alias('model.activeDirectory'),
username: '',
password: '',
isEnabled: alias('model.activeDirectory.enabled'),
adConfig: alias('model.activeDirectory'),
init() {
this._super(...arguments);
if (this.get('adConfig')){
if (get(this, 'adConfig')){
this.tlsChanged();
}
},
createDisabled: computed('username.length','password.length', function() {
return !this.get('username.length') || !this.get('password.length');
return !get(this, 'username.length') || !get(this, 'password.length');
}),
numUsers: computed('adConfig.allowedIdentities.@each.externalIdType','userType','groupType', function() {
return (this.get('adConfig.allowedIdentities')||[]).filterBy('externalIdType', this.get('userType')).get('length');
return ( get(this, 'adConfig.allowedPrincipalIds') || [] ).filter(principal => principal.includes(C.PROJECT.TYPE_ACTIVE_DIRECTORY_USER)).get('length');
}),
numGroups: computed('adConfig.allowedIdentities.@each.externalIdType','userType','groupType', function() {
return (this.get('adConfig.allowedIdentities')||[]).filterBy('externalIdType', this.get('groupType')).get('length');
return ( get(this, 'adConfig.allowedPrincipalIds') || [] ).filter(principal => principal.includes(C.PROJECT.TYPE_ACTIVE_DIRECTORY_GROUP)).get('length');
}),
configServers: computed('adConfig.servers', {
get() {
return (this.get('adConfig.servers')||[]).join(',');
return (get(this, 'adConfig.servers')||[]).join(',');
},
set(key, value) {
this.set('adConfig.servers', value.split(','));
set(this, 'adConfig.servers', value.split(','));
return value;
}
}),
tlsChanged: observer('adConfig.tls', function() {
var on = (this.get('adConfig.tls')||false);
var port = parseInt(this.get('adConfig.port'),10);
var on = (get(this, 'adConfig.tls')||false);
var port = parseInt(get(this, 'adConfig.port'),10);
if ( on && port === PLAIN_PORT )
{
this.set('adConfig.port', TLS_PORT);
set(this, 'adConfig.port', TLS_PORT);
}
else if ( !on /* && port === TLS_PORT */ ) // TODO 2.0
{
this.set('adConfig.port', PLAIN_PORT);
this.set('adConfig.tls', false);
set(this, 'adConfig.port', PLAIN_PORT);
set(this, 'adConfig.tls', false);
}
}),
@ -76,7 +76,7 @@ export default Controller.extend({
test: function() {
this.send('clearError');
var model = this.get('adConfig');
var model = get(this, 'adConfig');
model.setProperties({
accessMode: 'unrestricted',
});
@ -84,16 +84,16 @@ export default Controller.extend({
var errors = model.validationErrors();
if ( errors.get('length') )
{
this.set('errors', errors);
set(this, 'errors', errors);
}
else
{
this.set('testing', true);
set(this, 'testing', true);
model.doAction('testAndApply', {
activeDirectoryConfig: model,
enabled: true,
username: this.get('username'),
password: this.get('password'),
username: get(this, 'username'),
password: get(this, 'password'),
}).then( () => {
this.send('waitAndRefresh');
}).catch((err) => {
@ -111,25 +111,25 @@ export default Controller.extend({
},
promptDisable: function() {
this.set('confirmDisable', true);
set(this, 'confirmDisable', true);
later(this, function() {
this.set('confirmDisable', false);
set(this, 'confirmDisable', false);
}, 10000);
},
gotError: function(err) {
this.set('errors', [Errors.stringify(err)]);
this.set('testing', false);
set(this, 'errors', [Errors.stringify(err)]);
set(this, 'testing', false);
},
clearError: function() {
this.set('errors', null);
set(this, 'errors', null);
},
disable: function() {
this.send('clearError');
var model = this.get('adConfig');
var model = get(this, 'adConfig');
model.setProperties({
enabled: false,
});
@ -140,7 +140,7 @@ export default Controller.extend({
}).catch((err) => {
this.send('gotError', err);
}).finally(() => {
this.set('confirmDisable', false);
set(this, 'confirmDisable', false);
});
},
},

View File

@ -4,6 +4,7 @@ import { once, later } from '@ember/runloop';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import Controller from '@ember/controller';
import C from 'ui/utils/constants';
export default Controller.extend({
github: service(),
@ -52,13 +53,12 @@ export default Controller.extend({
}),
numUsers: computed('githubConfig.allowedPrincipals.@each.externalIdType','wasRestricted', function() {
return 3; //TODO
// return get(this, 'githubConfig.principals').filterBy('externalIdType',C.PROJECT.TYPE_GITHUB_USER).get('length');
return ( get(this, 'githubConfig.allowedPrincipalIds') || []).filter(principal => principal.includes(C.PROJECT.TYPE_GITHUB_USER)).get('length');
}),
numOrgs: computed('githubConfig.allowedPrincipals.@each.externalIdType','wasRestricted',function() {
return 4; //TODO
// return get(this, 'githubConfig.principals').filterBy('externalIdType',C.PROJECT.TYPE_GITHUB_ORG).get('length');
return ( get(this, 'githubConfig.allowedPrincipalIds') || []).filter(principal => principal.includes(C.PROJECT.TYPE_GITHUB_ORG)).get('length');
}),
destinationUrl: computed(function() {

View File

@ -46,7 +46,7 @@
<div class="row">
<div class="col span-4">
<h3>{{t 'ldap.accessEnabled.general.header'}}</h3>
<div><b>{{t 'ldap.accessEnabled.general.server'}} </b> <span class="text-muted">{{adConfig.server}}:{{adConfig.port}}</span></div>
<div><b>{{t 'ldap.accessEnabled.general.server'}} </b> <span class="text-muted">{{adConfig.servers.firstObject}}:{{adConfig.port}}</span></div>
<div><b>{{t 'ldap.accessEnabled.general.tls'}} </b> <span class="text-muted">{{if adConfig.tls "Yes" "No"}}</span></div>
<div><b>{{t 'ldap.accessEnabled.general.serviceAccount'}} </b> <span class="text-muted">{{adConfig.serviceAccountUsername}}</span></div>
{{#unless isOpenLdap}}

View File

@ -1,110 +0,0 @@
import { get, set, computed } from '@ember/object';
import { next } from '@ember/runloop';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import C from 'ui/utils/constants';
export default Component.extend({
access: service(),
cookies: service(),
isCaas: computed('app.mode', function() {
return this.get('app.mode') === 'caas' ? true : false;
}),
waiting: null,
username: null,
rememberUsername: false,
password: null,
shown: false,
provider: null,
readableProvider: null,
actions: {
showLocal() {
this.toggleProperty('shown');
next(this, 'focusSomething');
},
authenticate: function() {
const username = get(this, 'username');
let password = get(this, 'password');
const remember = get(this, 'rememberUsername');
if (password && get(this, 'provider') === 'local') {
password = password.trim();
}
const code = {
username: username,
password: password,
};
if ( remember ) {
if (get(this, 'provider') === 'local') {
get(this, 'cookies').setWithOptions(C.COOKIE.USERNAME, username, {expire: 365, secure: 'auto'});
} else {
get(this, 'cookies').setWithOptions(`${get(this, 'provider').toUpperCase()}-USERNAME`, username, {expire: 365, secure: 'auto'});
}
} else {
get(this, 'cookies').remove(C.COOKIE.USERNAME);
}
set(this, 'password', '');
if ( get(this,'access.providers') ) {
this.sendAction('action', get(this, 'provider'), code);
}
}
},
init() {
this._super(...arguments);
var username = null;
if (get(this, 'provider') === 'local') {
username = get(this, `cookies.${C.COOKIE.USERNAME}`);
} else {
username = get(this, `cookies.${get(this, 'provider').toUpperCase()}-USERNAME`);
}
if ( username ) {
set(this, 'username', username);
set(this, 'rememberUsername', true);
}
if (get(this, 'provider')) {
let pv = null;
switch(get(this, 'provider')) {
case 'activedirectory':
pv = 'Active Directory';
break;
case 'local':
default:
pv = 'Local Auth';
break;
}
set(this, 'readableProvider', pv);
// console.log(this.get('provider'));
}
},
focusSomething() {
if ( this.isDestroyed || this.isDestroying ) {
return;
}
let elem = this.$('#login-username-ad');
if ( get(this, 'username') ) {
elem = this.$('#login-password-ad');
}
if ( elem && elem[0] ) {
elem[0].focus();
}
},
didInsertElement() {
next(this, 'focusSomething');
},
});

View File

@ -1,42 +0,0 @@
{{#if shown}}
<form {{action "authenticate" on="submit"}} class="text-left mt-10">
<div class="mt-20">
<div>
{{#if isCaas}}
<label class="acc-label">{{t 'loginUserPass.caasLabel'}}</label>
{{else}}
<label class="acc-label">{{t 'loginUserPass.userLabel'}}</label>
{{/if}}
<div class="pull-right">
<label class="acc-label">{{t 'loginUserPass.remember'}} {{input type="checkbox" checked=rememberUsername}}</label>
</div>
</div>
<div>
{{input type="text" id="login-username-ad" autocomplete="username" class="form-control login-user" value=username placeholder=(t 'loginUserPass.userPlaceholder')}}
</div>
</div>
<div class="pt-15 pb-15">
<label class="acc-label">{{t 'loginUserPass.passwordLabel'}}</label>
<div>
{{input id="login-password-ad" autocomplete="password" type="password" class="form-control login-pass" value=password}}
</div>
</div>
<p class="text-center">
<button disabled={{waiting}} class="btn bg-primary" {{action "authenticate"}}>
{{#if waiting}}
<i class="icon icon-spinner icon-spin"></i> {{t 'loginUserPass.loggingInLabel'}}
{{else}}
{{t 'loginUserPass.loginLabel' provider=readableProvider}}
{{/if}}
</button>
</p>
</form>
{{else}}
{{#if (eq provider 'local')}}
<a class="link-text text-small mt-10 hand" {{action 'showLocal'}}>{{t 'loginUserPass.local'}}</a>
{{else}}
<a class="btn bg-primary" {{action 'showLocal'}}> <i class="icon icon-key"></i> {{t 'loginUserPass.provider' kind=readableProvider}}</a>
{{/if}}
{{/if}}

View File

@ -108,4 +108,9 @@ export default Component.extend({
didInsertElement() {
next(this, 'focusSomething');
},
willDestroyElement() {
set(this, 'shown', false);
},
});

View File

@ -12,14 +12,14 @@
</div>
</div>
<div>
{{input type="text" id="login-username" autocomplete="username" class="form-control login-user" value=username placeholder=(t 'loginUserPass.userPlaceholder')}}
{{input type="text" id=(concat "login-username-" provider) autocomplete="username" class="form-control login-user" value=username placeholder=(t 'loginUserPass.userPlaceholder')}}
</div>
</div>
<div class="pt-15 pb-15">
<label class="acc-label">{{t 'loginUserPass.passwordLabel'}}</label>
<div>
{{input id="login-password" autocomplete="password" type="password" class="form-control login-pass" value=password}}
{{input id=(concat "login-password-" provider) autocomplete="password" type="password" class="form-control login-pass" value=password}}
</div>
</div>

View File

@ -24,6 +24,9 @@ export default Controller.extend({
isForbidden: equal('errorCode', '403'),
waiting: false,
adWaiting: false,
localWaiting: false,
shibbolethWaiting: false,
errorMsg: null,
errorCode: null,
resetPassword: false,
@ -76,7 +79,6 @@ export default Controller.extend({
},
actions: {
started() {
setProperties(this, {
'waiting': true,
@ -84,6 +86,27 @@ export default Controller.extend({
});
},
waiting(provider) {
// setProperties(this, {
// 'waiting': true,
// 'errorMsg': null,
// });
set(this, 'errorMsg', null);
switch (provider) {
case 'local':
this.toggleProperty('localWaiting');
break;
case 'activedirectory':
this.toggleProperty('adWaiting');
break;
case 'shibboleth':
this.toggleProperty('shibbolethWaiting');
break;
default:
break;
}
},
complete(success) {
if (success) {
this.shouldSetServerUrl().then((proceed) => {
@ -102,7 +125,7 @@ export default Controller.extend({
},
authenticate(provider, code) {
this.send('started');
this.send('waiting', provider);
later(() => {
get(this, 'access').login(provider, code).then((user) => {
@ -118,9 +141,10 @@ export default Controller.extend({
get(this, 'access').set('userCode', null);
get(this, 'access').set('firstLogin', false);
this.send('complete', true);
this.send('waiting', provider);
}
}).catch((err) => {
set(this, 'waiting', false);
this.send('waiting', provider);
if ( err && err.status === 401 ) {
let key = 'loginPage.error.authFailed'

View File

@ -54,8 +54,13 @@ export default Route.extend({
resetController(controller, isExisting /*, transition*/ ) {
if (isExisting) {
controller.set('changePassword', false);
controller.set('waiting',false);
controller.setProperties({
changePassword: false,
waiting: false,
adWaiting: false,
shibbolethWaiting: false,
localWaiting: false,
})
}
}
});

View File

@ -16,7 +16,7 @@
<br/>
{{login-shibboleth
action="started"
waiting=waiting
waiting=shibbolethWaiting
}}
{{/if}}
@ -25,23 +25,23 @@
{{/if}}
{{#if isActiveDirectory}}
{{login-ad
classNames="row"
{{login-user-pass
action="authenticate"
waiting=waiting
shown=true
classNames="row"
provider="activedirectory"
shown=true
waiting=adWaiting
}}
{{/if}}
{{#if isLocal}}
{{login-user-pass
classNames="row"
action="authenticate"
waiting=waiting
shown=onlyLocal
classNames="row"
onlyLocal=onlyLocal
provider="local"
shown=onlyLocal
waiting=localWaiting
}}
{{/if}}

View File

@ -5,7 +5,7 @@ import C from 'shared/utils/constants';
import EmberObject from '@ember/object'
import { get } from '@ember/object';
import { /* parseExternalId, */ parseHelmExternalId } from 'ui/utils/parse-externalid';
import { all } from 'rsvp';
import { all, allSettled } from 'rsvp';
const RANCHER_VERSION = 'rancherVersion';
const DEFAULT_BASE = 'kubernetes';
@ -47,7 +47,7 @@ export default Service.extend({
deps.push(this.fetchTemplate(extInfo.templateId, false));
});
return all(deps);
return allSettled(deps);
},
fetchCatalogs(opts) {

View File

@ -9,6 +9,7 @@ export default Component.extend({
questions: alias('selectedTemplate.questions'),
pasteOrUpload: false,
accept : '.yml, .yaml',
showHeader: true,
_boundChange : null,
didInsertElement() {
this.set('_boundChange', (event) => { this.change(event); });

View File

@ -3,8 +3,13 @@
<button class="btn btn-sm bg-primary" {{action 'showPaste'}}>{{t 'generic.paste'}} <span class="icon icon-copy"></span></button>
<button class="btn btn-sm bg-primary" {{action 'upload'}}>{{t 'generic.upload'}} <span class="icon icon-upload"></span></button>
</div>
<h4 class="mb-0">{{t 'inputAnswers.config'}}</h4>
<span class="protip">{{t 'inputAnswers.protip'}}</span>
<div>
{{#if showHeader}}
<h4 class="mb-0">{{t 'inputAnswers.config'}}</h4>
<span class="protip">{{t 'inputAnswers.protip'}}</span>
{{/if}}
&nbsp;
</div>
<div class="mt-20">
{{#if pasteOrUpload}}
{{textarea-autogrow

View File

@ -5,9 +5,19 @@
<div class="stack-info-row box">
<div class="stack-info-top row">
<div class="col">
{{model.displayName}}
{{#link-to "apps-tab.detail" model.id}}
{{model.displayName}}
{{/link-to}}
</div>
<div class="col text-right pull-right">
{{#upgrade-btn model=model classNames="btn-xs " as |btn|}}
{{#if btn.model.externalIdInfo.version}}
{{t (concat 'upgradeBtn.status.' btn.upgradeStatus)}}
<span class="text-small">({{btn.latestVersion}})</span>
{{else}}
{{t (concat 'upgradeBtn.status.' btn.upgradeStatus)}}
{{/if}}
{{/upgrade-btn}}
{{badge-state model=model classNames="btn-xs"}}
{{action-menu model=model classNames="inline-block"}}
</div>
@ -25,17 +35,15 @@
{{model.pods.length}}
</small>
</div>
{{#upgrade-btn model=model classNames="btn-sm" as |btn|}}
{{model.externalIdInfo.version}}
{{#if btn.model.externalIdInfo.version}}
{{t (concat 'upgradeBtn.status.' btn.upgradeStatus)}}
<span class="text-small">({{btn.latestVersion}})</span>
{{else}}
{{t (concat 'upgradeBtn.status.' btn.upgradeStatus)}}
<div class="col">
{{#if model.workloads}}
<div class="clip text-small">
{{#each model.workloads as |workload|}}
{{workload.displayEndpoints}}
{{/each}}
</div>
{{/if}}
{{/upgrade-btn}}
</div>
</div>
</div>
</div>
{{#if expanded}}
{{/if}}
</div>

View File

@ -121,7 +121,7 @@ export default Component.extend(NewOrEdit, {
value: file.name
}
});
files.addObject({label: 'answers', value: 'answers'});
files.addObject({label: 'answers.yaml', value: 'answers'});
return files.sortBy('label');
}),

View File

@ -8,131 +8,134 @@
</section>
{{/if}}
<section class="{{sectionClass}} row">
<div class="col span-3">
{{#if templateResource.links.icon}}
<img src={{templateResource.links.icon}} alt={{templateResource.name}} class="mr-20" style="height:75px;max-width: 100%;">
{{/if}}
<div>
<small><strong>{{t 'newCatalog.catalog'}}</strong> <span class="text-capitalize">{{templateResource.catalogId}}</span></small>
<div class="accordion"> {{!-- container-section --}}
<div class="accordion-header"> {{!-- header-section --}}
<div class="title">
<span class="m-0 ">{{t 'newCatalog.appInfo'}}</span>
</div>
<div>
<small><strong>{{t 'newCatalog.kind'}}</strong> <span class="text-capitalize">{{templateKind}}</span></small>
</div>
<div class="accordion-content row"> {{!-- content-section --}}
<div class="col span-12 text-center">
{{#if templateResource.links.icon}}
<img src={{templateResource.links.icon}} alt={{templateResource.name}} class="mr-20" style="height:75px;max-width: 100%;">
{{/if}}
</div>
<div>
<small><strong>{{t 'newCatalog.category'}}</strong> {{join-array templateResource.categoryArray}}</small>
</div>
{{#if (eq templateResource.certifiedType 'rancher')}}
<small><strong>{{t 'newCatalog.support'}} </strong>{{t 'newCatalog.official'}}</small>
{{#if readmeContent}}
<div class="col span-12" style="overflow-y: auto; max-height: 500px;">
{{marked-down markdown=readmeContent}}
</div>
{{else}}
<small><strong>{{t 'newCatalog.support'}} </strong>{{t 'newCatalog.maintainedBy'}}</small>
{{/if}}
{{#if templateResource.maintainer}}
<div>
<small><strong>{{t 'newCatalog.maintainer'}}</strong> {{templateResource.maintainer}}</small>
</div>
{{/if}}
{{#if templateResource.license}}
<div>
<small><strong>{{t 'newCatalog.license'}}</strong> {{templateResource.license}}</small>
</div>
{{/if}}
{{#if templateResource.links.project}}
<small class="force-wrap"><strong>{{t 'newCatalog.url'}}</strong> <a href="{{templateResource.cleanProjectUrl}}" target="_blank">{{templateResource.cleanProjectUrl}}</a></small>
<h2 class="mb-10">
{{t (if editing titleUpgrade titleAdd) name=templateResource.name}}
</h2>
<p>{{templateResource.description}}</p>
{{/if}}
</div>
{{#if readmeContent}}
<div class="col span-9" style="overflow-y: auto; max-height: 500px;">
{{marked-down markdown=readmeContent}}
</div>
{{else}}
<h2 class="mb-10">
{{t (if editing titleUpgrade titleAdd) name=templateResource.name}}
</h2>
<p>{{templateResource.description}}</p>
{{/if}}
</section>
<section class="{{sectionClass}}">
<h4>{{t 'newCatalog.newApp'}}</h4>
{{form-name-description
model=catalogApp
nameRequired=true
descriptionShow=false
nameDisabled=showName
}}
</div>
{{#if showName}}
{{#advanced-section}}
<hr class="mt-20 mb-20"/>
{{form-namespace
namespace=primaryResource
mode='reuse'
errors=namespaceErrors
}}
{{/advanced-section}}
{{/if}}
</section>
<section class="{{sectionClass}}">
<h4>{{t 'newCatalog.templateVersion'}}</h4>
<div class="row">
<div class="col span-6">
{{new-select
classNames="form-control"
content=sortedVersions
prompt="newCatalog.version.prompt"
localizedPrompt=true
optionLabelPath="version"
optionValuePath="link"
value=selectedTemplateUrl
disabled=getTemplate.isRunning
}}
<p class="text-info">{{t (if editing selectVersionUpgrade selectVersionAdd)}}</p>
<div class="accordion">
<div class="accordion-header">
<div class="title">
<span class="m-0 ">{{t 'newCatalog.newApp'}}</span>
</div>
</div>
</section>
<div class="accordion-content row">
<div class="row">
<div class="col span-6">
{{form-name-description
model=catalogApp
nameRequired=true
descriptionShow=false
nameDisabled=showName
bothColClass="col span-12"
colClass="col span-12"
}}
</div>
<div class="col span-6" style="padding-top: 6px;"> {{!-- matches styles of form-name-description --}}
<label for="" class="acc-label">{{t 'newCatalog.templateVersion'}}</label>
{{new-select
classNames="form-control"
content=sortedVersions
prompt="newCatalog.version.prompt"
localizedPrompt=true
optionLabelPath="version"
optionValuePath="link"
value=selectedTemplateUrl
disabled=getTemplate.isRunning
}}
<p class="text-info">{{t (if editing selectVersionUpgrade selectVersionAdd)}}</p>
</div>
</div>
<div class="col span-12">
{{#if showName}}
{{#advanced-section}}
<hr class="mt-20 mb-20"/>
<section class="{{sectionClass}}">
{{#if getTemplate.isRunning}}
{{form-namespace
namespace=primaryResource
mode='reuse'
errors=namespaceErrors
}}
{{/advanced-section}}
{{/if}}
</div>
</div>
</div>
{{#if getTemplate.isRunning}}
<section class="row">
<div class="text-center">
<i class="icon icon-spinner icon-spin" style="font-size:36px;"></i>
</div>
{{/if}}
{{#if selectedTemplateModel}}
{{#if (eq templateKind 'native')}}
{{input-answers
selectedTemplate=selectedTemplateModel
}}
{{else}}
<h4>{{t 'newCatalog.helm.label'}}</h4>
<div class="span-12">
{{#if selectedTemplateModel.questions}}
{{input-answers
selectedTemplate=selectedTemplateModel
}}
</section>
{{else}}
<div class="accordion">
<div class="accordion-header">
<div class="title">
{{#if (or (eq templateKind 'native') selectedTemplateModel.questions)}}
<span class="m-0 block">{{t 'inputAnswers.config'}}</span>
<span class="help-block">{{t 'inputAnswers.protip'}}</span>
{{else}}
{{form-key-value
initialMap=catalogApp.answers
changed=(action (mut catalogApp.answers))
allowEmptyValue=false
editing=true
header=(t 'newCatalog.answers.label')
addActionLabel="newCatalog.answers.addAction"
keyLabel="newContainer.environment.keyLabel"
keyPlaceholder="newContainer.environment.keyPlaceholder"
valueLabel="newContainer.environment.valueLabel"
valuePlaceholder="newContainer.environment.valuePlaceholder"
}}
<span class="m-0 block">{{t 'newCatalog.helm.label'}}</span>
<span class="help-block">{{t 'newCatalog.helm.protip'}}</span>
{{/if}}
</div>
{{/if}}
{{/if}}
</section>
</div>
<div class="accordion-content row">
{{#if selectedTemplateModel}}
{{#if (eq templateKind 'native')}}
{{input-answers
selectedTemplate=selectedTemplateModel
showHeader=false
}}
{{else}}
<div class="span-12">
{{#if selectedTemplateModel.questions}}
{{input-answers
selectedTemplate=selectedTemplateModel
showHeader=false
}}
{{else}}
{{form-key-value
initialMap=catalogApp.answers
changed=(action (mut catalogApp.answers))
allowEmptyValue=false
editing=true
header=(t 'newCatalog.answers.label')
addActionLabel="newCatalog.answers.addAction"
keyLabel="newContainer.environment.keyLabel"
keyPlaceholder="newContainer.environment.keyPlaceholder"
valueLabel="newContainer.environment.valueLabel"
valuePlaceholder="newContainer.environment.valuePlaceholder"
}}
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
</div>
{{/if}}
{{#if (and selectedTemplateModel (not getTemplate.isRunning))}}
{{#if showPreview}}

View File

@ -1,6 +1,6 @@
{{#if hasBlock}}
{{ yield this}}
{{ else }}
{{yield this}}
{{else}}
{{#if currentVersion}}
{{#tooltip-element type="tooltip-basic" model=currentVersion tooltipTemplate='tooltip-static' aria-describedby="tooltip-base" tooltipFor="upgrade"}}
{{t (concat 'upgradeBtn.status.' upgradeStatus)}}

View File

@ -44,7 +44,7 @@ export default Mixin.create({
color: computed('upgradeStatus', function() {
switch ( get(this, 'upgradeStatus') ) {
case NONE:
return 'hide';
return 'none';
case CURRENT:
case LOADING:
return 'bg-default';
@ -98,10 +98,11 @@ export default Mixin.create({
},
updateStatus() {
let state = get(this, 'model.state');
let info = get(this, 'model.externalIdInfo');
let state = get(this, 'model.state');
let info = get(this, 'model.externalIdInfo');
let catalogTemplate = get(this, 'model.catalogTemplate');
let upgradeVersions={};
let upgradeVersions = {};
let allVersions = {};
if ( state === 'upgraded' ) {
set(this, 'upgradeStatus', UPGRADED);
@ -119,7 +120,14 @@ export default Mixin.create({
set(this, 'upgradeStatus', NONE);
}
upgradeVersions = parseUpgradeVersions(catalogTemplate.get('versionLinks'), info.version, get(this, 'model'));
if ( catalogTemplate ) {
upgradeVersions = parseUpgradeVersions(
get(catalogTemplate, 'versionLinks'),
get(info, 'version'),
get(this, 'model')
);
get(catalogTemplate, 'versionLinks');
}
if (Object.keys(upgradeVersions).length >= 1) {
setProperties(this, {
@ -127,7 +135,10 @@ export default Mixin.create({
latestVersion: Object.keys(upgradeVersions)[Object.keys(upgradeVersions).length-1],
});
} else {
set(this, 'upgradeStatus', NONE);
setProperties(this, {
upgradeStatus: CURRENT,
latestVersion: get(info, 'version'),
});
}
// console.log('upgradeVersions', upgradeVersions);
@ -149,7 +160,7 @@ export default Mixin.create({
}
setProperties(this, {
allVersions: catalogTemplate.get('versionLinks'),
allVersions: allVersions,
upgradeVersions: upgradeVersions
});
return;

View File

@ -188,18 +188,20 @@ var C = {
},
PROJECT: {
TYPE_RANCHER: 'local',
TYPE_AZURE_USER: 'azuread_user',
TYPE_AZURE_GROUP: 'azuread_group',
TYPE_GITHUB_USER: 'github_user',
TYPE_GITHUB_TEAM: 'github_team',
TYPE_GITHUB_ORG: 'github_org',
TYPE_LDAP_USER: 'ldap_user',
TYPE_LDAP_GROUP: 'ldap_group',
TYPE_OPENLDAP_USER: 'openldap_user',
TYPE_OPENLDAP_GROUP: 'openldap_group',
TYPE_SHIBBOLETH_USER: 'shibboleth_user',
TYPE_SHIBBOLETH_GROUP: 'shibboleth_group',
TYPE_RANCHER: 'local',
TYPE_ACTIVE_DIRECTORY_USER: 'activedirectory_user',
TYPE_ACTIVE_DIRECTORY_GROUP: 'activedirectory_group',
TYPE_AZURE_USER: 'azuread_user',
TYPE_AZURE_GROUP: 'azuread_group',
TYPE_GITHUB_USER: 'github_user',
TYPE_GITHUB_TEAM: 'github_team',
TYPE_GITHUB_ORG: 'github_org',
TYPE_LDAP_USER: 'ldap_user',
TYPE_LDAP_GROUP: 'ldap_group',
TYPE_OPENLDAP_USER: 'openldap_user',
TYPE_OPENLDAP_GROUP: 'openldap_group',
TYPE_SHIBBOLETH_USER: 'shibboleth_user',
TYPE_SHIBBOLETH_GROUP: 'shibboleth_group',
PERSON: 'person',
TEAM: 'team',

View File

@ -167,6 +167,32 @@ accountsPage:
modal:
password: Change Password
appDetailPage:
header: "App: {appName}"
output:
title: Launch Output
detail: TBD
workloads:
title: Workloads
detail: Workloads created for this application.
nodata: No workloads were created for this application.
ingress:
title: Ingress Rules
detail: Ingress rules created for this application.
nodata: No ingress rules were created for this application.
services:
title: Services
detail: Services created with this application
nodata: No services were created for this application.
volumes:
title: Volumes
detail: Persistant Volume claims created with this application
nodata: No volume claims were made for this application.
secrets:
title: Secrets
detail: Secrets associated with this application
nodata: This application has no secrets
podSecurityPoliciesPage:
index:
header: Pod Security Policies
@ -311,8 +337,6 @@ authPage:
buttonText:
pre: Authenticate with IDP
post: Waiting to hear back from IDP
providerName:
shibboleth: Shibboleth
root:
@ -323,33 +347,32 @@ authPage:
header:
enabled:
label: "{github} is enabled"
required: "{appName} is configured to allow access to authorized users and organizations."
restricted: "{appName} is configured to allow access to project members, authorized users and organizations."
# required: |
# {appName} is configured to allow access to {orgs, plural,
# =0 {no organizations}
# =1 {# organization}
# other {# organizations}
# } and {users, plural,
# =0 {no users}
# =1 {# user}
# other {# users}
# }.
# restricted: |
# {appName} is configured to allow access to project members, {orgs, plural,
# =0 {no organizations}
# =1 {# organization}
# other {# organizations}
# } and {users, plural,
# =0 {no users}
# =1 {# user}
# other {# users}
# }.
# unrestricted: "{appName} is configured to allow access to any {github} user."
# required: "{appName} is configured to allow access to authorized users and organizations."
# restricted: "{appName} is configured to allow access to project members, authorized users and organizations."
required: |
{appName} is configured to allow access to {orgs, plural,
=0 {no organizations}
=1 {# organization}
other {# organizations}
} and {users, plural,
=0 {no users}
=1 {# user}
other {# users}
}.
restricted: |
{appName} is configured to allow access to project members, {orgs, plural,
=0 {no organizations}
=1 {# organization}
other {# organizations}
} and {users, plural,
=0 {no users}
=1 {# user}
other {# users}
}.
# unrestricted: "{appName} is configured to allow access to any {github} user."
disabled:
label: GitHub is not configured
warning: "{appName} can be configured to restrict access to a set of GitHub users and organization members."
authenticated:
header:
text: Authentication
@ -398,7 +421,6 @@ authPage:
buttonText:
pre: Authenticate with GitHub
post: Waiting to hear back from GitHub
azuread:
header:
enabled: 'Azure AD Authentication is <b>enabled</b>'
@ -439,8 +461,6 @@ authPage:
label: Login Password
pre: Authenticate with Azure
post: Waiting to hear back from Azure
localAuth:
header:
enabled: 'Local Authentication is enabled'
@ -652,6 +672,7 @@ ingressPage:
header: 'Ingress: {name}'
table:
noMatch: No ingresses match the current search.
noData: You do not have any ingress rules yet.
containerPage:
header: 'Container: {name}'
portsTab:
@ -4162,6 +4183,7 @@ newCatalog:
maintainer: "Maintainer:"
newNamespace: New Namespace
newApp: New Application
appInfo: Application Read Me
noConfig: This template has no configuration options
official: Officially Certified
preview: Preview
@ -4945,7 +4967,7 @@ upgradeBtn:
version:
current: 'Current'
status:
none: 'None'
none: 'Upgrade: None'
loading: 'Checking upgrades...'
current: 'Up to date'
available: 'Upgrade available'