Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7415,6 +7415,8 @@ advancedSettings:
hide: Hide
none: None
modified: Modified
setByExtension: "Set by extension: {name}"
setByExtensionNotEditable: This setting is provided by an extension and can not be edited
edit:
label: Edit Setting
changeSetting: "Change Setting:"
Expand Down Expand Up @@ -7453,6 +7455,7 @@ advancedSettings:
'brand': Folder name for an alternative theme defined in '/assets/brand'
'hide-local-cluster': Hide the local cluster
'agent-tls-mode': "Rancher Certificate Verification. In `strict` mode the agents (system, cluster, fleet, etc) will only trust Rancher installations which are using a certificate signed by the CABundle in the `cacerts` setting. When the mode is system-store, the agents will trust any certificate signed by a CABundle in the operating system’s trust store."
'ui-brand': UI Brand to use - loads assets from a different folder or extension
'k3s-based-upgrader-uninstall-concurrency': Defines the maximum number of clusters in which Rancher can concurrently uninstall the legacy `rancher-k3s-upgrader` or `rancher-rke2-upgrader` app from imported K3s or RKE2 clusters.
warnings:
'agent-tls-mode': 'Changing this setting will cause all agents to be redeployed.'
Expand Down Expand Up @@ -7601,6 +7604,7 @@ banner:
branding:
label: Branding
directoryName: Brand Asset Directory Name
setByExtensionNoOverride: "Branding settings are set by the extension <b>{name}</b> and can not be overridden"
logos:
label: Logo
tip: 'Upload a logo to replace the Rancher logo in the top-level navigation header. Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
Expand Down
21 changes: 20 additions & 1 deletion shell/components/BrandImage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { mapGetters } from 'vuex';
import { MANAGEMENT } from '@shell/config/types';
import { SETTING } from '@shell/config/settings';
import { getSettingValue } from '@shell/utils/settings';
import { RegistrationType } from '@shell/core/types';

export default {
props: {
Expand Down Expand Up @@ -41,7 +43,7 @@ export default {
brand() {
const setting = this.managementSettings.filter((setting) => setting.id === SETTING.BRAND)[0] || {};

return setting.value;
return getSettingValue(setting, this.$plugin);
},

uiLogoLight() {
Expand Down Expand Up @@ -78,6 +80,11 @@ export default {
}
},

// Used when we look up images from extensions - we do this without the file extension
fileNameWithoutExtension() {
return this.fileName.split('.').slice(0, -1).join('.');
},

pathToBrandedImage() {
if (this.fileName === 'rancher-logo.svg' || this.supportCustomLogo) {
if (this.theme === 'dark' && this.uiLogoDark) {
Expand Down Expand Up @@ -113,11 +120,23 @@ export default {
return this.defaultPathToBrandedImage;
} else {
if (this.theme === 'dark' || this.dark) {
const file = this.$plugin.getDynamic(RegistrationType.IMAGE, `brand/${ this.brand }/dark/${ this.fileNameWithoutExtension }`);

if (file) {
return file;
}

try {
return require(`~shell/assets/brand/${ this.brand }/dark/${ this.fileName }`);
} catch {}
}
try {
const file = this.$plugin.getDynamic(RegistrationType.IMAGE, `brand/${ this.brand }/${ this.fileNameWithoutExtension }`);

if (file) {
return file;
}

return require(`~shell/assets/brand/${ this.brand }/${ this.fileName }`);
} catch {}

Expand Down
13 changes: 12 additions & 1 deletion shell/config/private-label.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SETTING } from './settings';
import { CURRENT_RANCHER_VERSION } from './version';
import { getSettingFromExtension } from '@shell/utils/settings';

export const ANY = 0;
export const STANDARD = 1;
Expand All @@ -22,8 +23,18 @@ export function setMode(m) {
mode = m;
}

export function setVendor(v) {
export function setVendor(v, $plugin) {
vendor = v;

// Check to see if the private label is set from an extension
if ($plugin) {
const pl = getSettingFromExtension('pl', $plugin);

if (pl?.value) {
vendor = pl.value;
}
}

setTitle();
}

Expand Down
81 changes: 81 additions & 0 deletions shell/core/plugin-brand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Brand, BrandTheme } from '@shell/core/types';
import { createCssVars } from '@shell/utils/color';

/**
* Map of keys in the brand images object to file name.
* Allows us to use nicer field names in the brand theme configuration
*/
const IMAGE_NAME_MAP: { [key: string]: string } = {
logo: 'rancher-logo',
errorBanner: 'error-desert-landscape',
login: 'login-landscape',
};

/**
* Takes a brand from an extension and:
* - Injects CSS for the brand to set colors
* - Adds the images from the brand
*
* @param brand Brand to be added
* @param imageReg Function to register an image
*/
export function createBrand(brand: Brand, imageReg: any): void {
// Apply dark theme as overrides to light theme
const darkTheme = {
...brand.lightTheme,
...brand.darkTheme,
};

// Create the css for the theme
createBrandTheme(brand.name, 'light', brand.lightTheme);
createBrandTheme(brand.name, 'dark', darkTheme);

// Add the images for the theme
addBrandImages(imageReg, brand.name, 'light', brand.lightTheme);
addBrandImages(imageReg, brand.name, 'dark', darkTheme);

// Register fav icon
if (brand.favicon) {
imageReg(`brand/${ brand.name }/favicon`, brand.favicon);
}
}

function addBrandImages(imageReg: any, name: string, theme: string, brandTheme?: BrandTheme) {
if (brandTheme?.images) {
for (const imgName in brandTheme.images) {
const value = (brandTheme.images as any)[imgName];
const themeDir = theme === 'light' ? '' : `/${ theme }`;
const fileName = IMAGE_NAME_MAP[imgName] || imgName;
const imageName = `brand/${ name }${ themeDir }/${ fileName }`;

imageReg(imageName, value);
}
}
}

function createBrandTheme(name: string, theme: string, brandTheme?: BrandTheme): void {
let css = `BODY.${ name }.theme-${ theme } {\n`;

if (brandTheme?.colors) {
for (const colorName in brandTheme.colors) {
const value = (brandTheme.colors as any)[colorName];
const cssVars = createCssVars(value, theme, colorName);

css += ` /* ${ colorName } */\n`;

for (const prop in cssVars) {
css += ` ${ prop.trim() }: ${ cssVars[prop] };\n`;
}

css += '\n';
}
}

css += '}';

const style = document.createElement('style');

style.textContent = css;

document.head.append(style);
}
20 changes: 20 additions & 0 deletions shell/core/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
LocationConfig,
ExtensionPoint,
TabLocation,
Setting,
Brand,
PluginRouteRecordRaw, RegisterStore, UnregisterStore, CoreStoreSpecifics, CoreStoreConfig, OnNavToPackage, OnNavAwayFromPackage, OnLogOut
} from './types';
import coreStore, { coreStoreModule, coreStoreState } from '@shell/plugins/dashboard-store';
Expand All @@ -33,6 +35,8 @@ export class Plugin implements IPlugin {
public onEnter: OnNavToPackage = () => Promise.resolve();
public onLeave: OnNavAwayFromPackage = () => Promise.resolve();
public _onLogOut: OnLogOut = () => Promise.resolve();
public settings: Setting[] = [];
public brands: Brand[] = [];

public uiConfig: { [key: string]: any } = {};

Expand Down Expand Up @@ -227,6 +231,22 @@ export class Plugin implements IPlugin {
});
}

setSetting(name: string, value: string, override = false) {
this.settings.push({
name,
value,
override
});
}

setImage(path: string, image: Function) {
this.register('image', path, image);
}

addBrand(brand: Brand) {
this.brands.push(brand);
}

addUninstallHook(hook: Function) {
this.uninstallHooks.push(hook);
}
Expand Down
26 changes: 25 additions & 1 deletion shell/core/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { clearModelCache } from '@shell/plugins/dashboard-store/model-loader';
import { Plugin } from './plugin';
import { PluginRoutes } from './plugin-routes';
import { UI_PLUGIN_BASE_URL } from '@shell/config/uiplugins';
import { ExtensionPoint } from './types';
import { ExtensionPoint, RegistrationType } from './types';
import { createBrand } from './plugin-brand';

const MODEL_TYPE = 'models';

Expand Down Expand Up @@ -297,6 +298,29 @@ export default function(context, inject, vueApp) {
store.dispatch('i18n/addLocale', localeObj);
});

// Brand
if (plugin.brands.length) {
plugin.brands.forEach((brand) => {
createBrand(brand, (name, fn) => this.register('image', name, fn));
});
}

// Brand
if (plugin.settings.length) {
plugin.settings.forEach((setting) => {
// Check if the setting has already been set by another extension
if (this.getDynamic(RegistrationType.SETTING, setting.name)) {
console.warning(`Setting ${ setting.name } has already been set by another extension - ignoring`); // eslint-disable-line no-console
} else {
this.register(RegistrationType.SETTING, setting.name, {
value: setting.value,
extension: plugin.name,
override: setting.override
});
}
});
}

// Routes
pluginRoutes.addRoutes(plugin, plugin.routes);

Expand Down
68 changes: 68 additions & 0 deletions shell/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,46 @@ export interface PackageMetadata {
// children: Route[];
// }

export enum RegistrationType {
// Used to register a setting
SETTING = 'setting', // eslint-disable-line no-unused-vars

// USed to register an image
IMAGE = 'image', // eslint-disable-line no-unused-vars
}

export enum Settings {
// Used to set the brand setting
BRAND = 'brand' // eslint-disable-line no-unused-vars
}

export interface Setting {
name: string;
value: string;
override: boolean;
}

export type BrandTheme = {
colors?: {
primary?: string; // Primary color
link?: string; // Link color
},
images?: {
logo?: string; // Logo image
banner?: string; // Banner shown on the home page
errorBanner?: string; // Banner shown on the error page
login?: string; // Graphic shown on the login and setup screens
}
}

// Brand consists of a name, a light theme and an optional dark theme
export type Brand = {
name: string;
lightTheme: BrandTheme;
darkTheme?: BrandTheme;
favicon?: string; // Icon to be used for thw UI in the browser
}

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 }
Expand Down Expand Up @@ -463,6 +503,18 @@ export interface DSLReturnType {
// weightType: (input, weight, forBasic)
}

/**
* Interface for Extensions manager
*/
export interface IExtensions {
/**
* Lookup the given extension type
* @param type Type to lookup (e.g. image)
* @param name Name of the extension point
*/
getDynamic(type: string, name: string): any;
}

/**
* Interface for a Dashboard plugin
*/
Expand Down Expand Up @@ -532,6 +584,22 @@ export interface IPlugin {
*/
setHomePage(component: any): void;

/**
* Sets the given setting to use in the UI
*
* @param name Setting name
* @param value Setting value
* @param override Indicates if the setting should override a setting from the backend (via the resource)
*/
setSetting(name: string, value: string, override?: boolean): void;

/**
* Sets an image override to use in the UI
* @param path Path of the image to set
* @param image Function that resolves the image (e.g. via a require)
*/
setImage(path: string, image: Function): void;

/**
* Add routes to the Vue Router
*/
Expand Down
Loading