From c9cd31f2bcea14d021c7c73a8bf9e08ff93ee998 Mon Sep 17 00:00:00 2001 From: GermanBluefox Date: Sun, 29 Sep 2024 18:46:36 +0800 Subject: [PATCH] Correcting links on intro tab: https://github.com/ioBroker/ioBroker.admin/issues/2708 --- packages/admin/src-admin/src/AdminUtils.tsx | 828 ------------------ .../src/components/Intro/IntroCard.tsx | 2 +- .../src-admin/src/helpers/AdminUtils.tsx | 435 +++++++++ packages/admin/src-admin/src/helpers/utils.ts | 446 +++++++++- packages/admin/src-admin/src/tabs/Intro.tsx | 148 +--- packages/admin/src/lib/utils.ts | 441 ++++++++++ 6 files changed, 1350 insertions(+), 950 deletions(-) delete mode 100644 packages/admin/src-admin/src/AdminUtils.tsx create mode 100644 packages/admin/src-admin/src/helpers/AdminUtils.tsx create mode 100644 packages/admin/src/lib/utils.ts diff --git a/packages/admin/src-admin/src/AdminUtils.tsx b/packages/admin/src-admin/src/AdminUtils.tsx deleted file mode 100644 index 97fbfcfef..000000000 --- a/packages/admin/src-admin/src/AdminUtils.tsx +++ /dev/null @@ -1,828 +0,0 @@ -import semver from 'semver'; -import { type Translate } from '@iobroker/adapter-react-v5'; - -declare module '@mui/material/Button' { - interface ButtonPropsColorOverrides { - grey: true; - } -} - -const ANSI_RESET = 0; -const ANSI_RESET_COLOR = 39; -const ANSI_RESET_BG_COLOR = 49; -const ANSI_BOLD = 1; -const ANSI_RESET_BOLD = 22; - -export interface Style { - color?: string; - backgroundColor?: string; - fontWeight?: string; -} - -const STYLES: Record = { - 30: { color: 'black' }, // ANSI_BLACK - 31: { color: 'red' }, // ANSI_RED - 32: { color: 'green' }, // ANSI_GREEN - 33: { color: 'yellow' }, // ANSI_YELLOW - 34: { color: 'blue' }, // ANSI_BLUE - 35: { color: 'purple' }, // ANSI_PURPLE - 36: { color: 'cyan' }, // ANSI_CYAN - 37: { color: 'white' }, // ANSI_WHITE - - 90: { color: 'grey' }, // ANSI_BRIGHT_BLACK - 91: { color: 'lightred' }, // ANSI_BRIGHT_RED - 92: { color: 'lightgreen' }, // ANSI_BRIGHT_GREEN - 93: { color: 'lightyellow' }, // ANSI_BRIGHT_YELLOW - 94: { color: 'lightblue' }, // ANSI_BRIGHT_BLUE - 95: { color: 'lightpurple' }, // ANSI_BRIGHT_PURPLE - 96: { color: 'lightcyan' }, // ANSI_BRIGHT_CYAN - 97: { color: 'white' }, // ANSI_BRIGHT_WHITE - - 40: { backgroundColor: 'black' }, // ANSI_BG_BLACK - 41: { backgroundColor: 'red' }, // ANSI_BG_RED - 42: { backgroundColor: 'green' }, // ANSI_BG_GREEN - 43: { backgroundColor: 'yellow' }, // ANSI_BG_YELLOW - 44: { backgroundColor: 'blue' }, // ANSI_BG_BLUE - 45: { backgroundColor: 'purple' }, // ANSI_BG_PURPLE - 46: { backgroundColor: 'cyan' }, // ANSI_BG_CYAN - 47: { backgroundColor: 'white' }, // ANSI_BG_WHITE - - 100: { backgroundColor: 'grey' }, // ANSI_BRIGHT_BG_BLACK - 101: { backgroundColor: 'lightred' }, // ANSI_BRIGHT_BG_RED - 102: { backgroundColor: 'lightgreen' }, // ANSI_BRIGHT_BG_GREEN - 103: { backgroundColor: 'lightyellow' }, // ANSI_BRIGHT_BG_YELLOW - 104: { backgroundColor: 'lightblue' }, // ANSI_BRIGHT_BG_BLUE - 105: { backgroundColor: 'lightpurple' }, // ANSI_BRIGHT_BG_PURPLE - 106: { backgroundColor: 'lightcyan' }, // ANSI_BRIGHT_BG_CYAN - 107: { backgroundColor: 'white' }, // ANSI_BRIGHT_BG_WHITE -}; - -class AdminUtils { - /** - * Perform JSON parse/stringify with type inference - * - * @param obj the object to clone - */ - static deepClone>(obj: T): T { - return JSON.parse(JSON.stringify(obj)); - } - - /** - * Format bytes to MB or GB - * - * @param bytes the number of bytes - */ - static formatRam(bytes: number): string { - const GB = Math.floor((bytes / (1024 * 1024 * 1024)) * 10) / 10; - bytes %= 1024 * 1024 * 1024; - const MB = Math.floor((bytes / (1024 * 1024)) * 10) / 10; - let text = ''; - - if (GB > 1) { - text += `${GB} GB`; - } else { - text += `${MB} MB`; - } - - return text; - } - - static formatSpeed(mhz: number): string { - return `${mhz} MHz`; - } - - static formatBytes(bytes: number): string { - if (Math.abs(bytes) < 1024) { - return `${bytes} B`; - } - - const units = ['KB', 'MB', 'GB']; - // const units = ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB']; - let u = -1; - - do { - bytes /= 1024; - ++u; - } while (Math.abs(bytes) >= 1024 && u < units.length - 1); - - return `${bytes.toFixed(1)} ${units[u]}`; - } - - static getFileExtension(fileName: string): string | null { - const pos = fileName.lastIndexOf('.'); - if (pos !== -1) { - return fileName.substring(pos + 1).toLowerCase(); - } - return null; - } - - // Big thanks to: https://stackoverflow.com/questions/35969656/how-can-i-generate-the-opposite-color-according-to-current-color - static invertColor(hex: string, bw: boolean): string { - if (hex === undefined || hex === null || hex === '' || typeof hex !== 'string') { - return ''; - } - if (hex.indexOf('#') === 0) { - hex = hex.slice(1); - } - // convert 3-digit hex to 6-digits. - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - if (hex.length !== 6) { - throw new Error('Invalid HEX color.'); - } - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - - if (bw) { - // http://stackoverflow.com/a/3943023/112731 - return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#FFFFFF'; - } - // invert color components - const finalR = (255 - r).toString(16); - const finalG = (255 - g).toString(16); - const finalB = (255 - b).toString(16); - // pad each with zeros and return - return `#${finalR.padStart(2, '0')}${finalG.padStart(2, '0')}${finalB.padStart(2, '0')}`; - } - - /** - * Format number in seconds to time text - * - * @param seconds the number of seconds - * @param t i18n.t function - */ - static formatSeconds(seconds: number, t: Translate): string { - const days = Math.floor(seconds / (3600 * 24)); - let minutesRes: string; - let secondsRes: string; - let hoursRes: string; - - seconds %= 3600 * 24; - const hours = Math.floor(seconds / 3600); - - if (hours < 10) { - hoursRes = `0${hours}`; - } else { - hoursRes = hours.toString(); - } - seconds %= 3600; - const minutes = Math.floor(seconds / 60); - if (minutes < 10) { - minutesRes = `0${minutes}`; - } else { - minutesRes = minutes.toString(); - } - - seconds %= 60; - seconds = Math.floor(seconds); - if (seconds < 10) { - secondsRes = `0${seconds}`; - } else { - secondsRes = seconds.toString(); - } - - let text = ''; - if (days) { - text += `${days} ${t('daysShortText')} `; - } - text += `${hoursRes}:${minutesRes}:${secondsRes}`; - - return text; - } - - // internal use - static _replaceLink( - link: string, - objects: Record, - adapterInstance: string, - attr: string, - placeholder: string, - hosts: Record, - hostname: string, - adminInstance: string, - ): string { - if (attr === 'protocol') { - attr = 'secure'; - } - - try { - const object = objects[`system.adapter.${adapterInstance}`]; - - if (link && object) { - if (attr === 'secure') { - link = link.replace(`%${placeholder}%`, object.native[attr] ? 'https' : 'http'); - } else { - let value = object.native[attr]; - // workaround for port - if ((attr === 'webinterfacePort' || attr === 'port') && (!value || value === '0')) { - if (object.native.secure === true) { - value = 443; - } else { - value = 80; - } - } - - if (attr === 'bind' || attr === 'ip') { - let ip = object.native.bind || object.native.ip; - if (ip === '0.0.0.0') { - ip = AdminUtils.getHostname(object, objects, hosts, hostname, adminInstance); - } - if (!link.includes(`%${placeholder}%`)) { - link = link.replace(`%native_${placeholder}%`, ip || ''); - } else { - link = link.replace(`%${placeholder}%`, ip || ''); - } - } else if (!link.includes(`%${placeholder}%`)) { - link = link.replace(`%native_${placeholder}%`, value); - } else { - link = link.replace(`%${placeholder}%`, value); - } - } - } else { - console.log(`Cannot get link ${attr}`); - link = link.replace(`%${placeholder}%`, ''); - } - } catch (error) { - console.log(error); - } - return link; - } - - static ip2int(ip: string): number { - return ip.split('.').reduce((ipInt, octet) => (ipInt << 8) + parseInt(octet, 10), 0) >>> 0; - } - - static findNetworkAddressOfHost(obj: ioBroker.HostObject, localIp: string): null | string { - const networkInterfaces = obj?.native?.hardware?.networkInterfaces; - if (!networkInterfaces) { - return null; - } - - let hostIp; - for (const networkInterface of Object.values(networkInterfaces)) { - if (!networkInterface) { - continue; - } - for (let i = 0; i < networkInterface.length; i++) { - const ip = networkInterface[i]; - if (ip.internal) { - return; - } - if (localIp.includes(':') && ip.family !== 'IPv6') { - return; - } - if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { - return; - } - if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { - // if DNS name - hostIp = ip.address; - } else if ( - ip.family === 'IPv4' && - localIp.includes('.') && - (AdminUtils.ip2int(localIp) & AdminUtils.ip2int(ip.netmask)) === - (AdminUtils.ip2int(ip.address) & AdminUtils.ip2int(ip.netmask)) - ) { - hostIp = ip.address; - } else { - hostIp = ip.address; - } - } - } - - if (!hostIp) { - for (const networkInterface of Object.values(networkInterfaces)) { - if (!networkInterface) { - continue; - } - for (let i = 0; i < networkInterface.length; i++) { - const ip = networkInterface[i]; - if (ip.internal) { - return; - } - if (localIp.includes(':') && ip.family !== 'IPv6') { - return; - } - if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { - return; - } - if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { - // if DNS name - hostIp = ip.address; - } else { - hostIp = ip.address; - } - } - } - } - - if (!hostIp) { - for (const networkInterface of Object.values(networkInterfaces)) { - if (!networkInterface) { - continue; - } - for (let i = 0; i < networkInterface.length; i++) { - const ip = networkInterface[i]; - if (ip.internal) { - return; - } - hostIp = ip.address; - } - } - } - - return hostIp; - } - - static getHostname( - instanceObj: ioBroker.InstanceObject, - objects: Record, - hosts: Record, - currentHostname: string, - adminInstance: string, - ): string { - if (!instanceObj || !instanceObj.common) { - return null; - } - - let hostname; - // check if the adapter from the same host as admin - const adminHost = objects[`system.adapter.${adminInstance}`]?.common?.host; - if (instanceObj.common.host !== adminHost) { - // find IP address - const host = hosts[`system.host.${instanceObj.common.host}`]; - if (host) { - const ip = AdminUtils.findNetworkAddressOfHost(host, currentHostname); - if (ip) { - hostname = ip; - } else { - console.warn(`Cannot find suitable IP in host ${instanceObj.common.host} for ${instanceObj._id}`); - return null; - } - } else { - console.warn(`Cannot find host ${instanceObj.common.host} for ${instanceObj._id}`); - return null; - } - } else { - hostname = currentHostname; - } - - return hostname; - } - - /** - * Convert the template link to string - */ - static replaceLink( - /** pattern for link */ - link: string, - /** adapter name */ - adapter: string, - /** adapter instance number */ - instance: number, - context: { - instances: Record; - hostname: string; - adminInstance: string; - hosts: Record; - }, - ): { - url: string; - port: number; - instance?: string; - }[] { - const _urls: { - url: string; - port: number; - instance?: string; - }[] = []; - let port: number; - - if (link) { - const instanceObj = context.instances[`system.adapter.${adapter}.${instance}`]; - const native = instanceObj?.native || {}; - - const placeholders = link.match(/%(\w+)%/g); - - if (placeholders) { - for (let p = 0; p < placeholders.length; p++) { - let placeholder = placeholders[p]; - - if (placeholder === '%ip%') { - let ip: string = (native.bind || native.ip) as string; - if (!ip || ip === '127.0.0.1' || ip === 'localhost' || ip === '0.0.0.0') { - // Check host - ip = AdminUtils.getHostname( - instanceObj, - context.instances, - context.hosts, - context.hostname, - context.adminInstance, - ); - } - - if (_urls.length) { - _urls.forEach(item => (item.url = item.url.replace('%ip%', ip))); - } else { - link = link.replace('%ip%', ip || ''); - } - } else if (placeholder === '%protocol%') { - const protocolVal: string | boolean = - native.secure === undefined ? native.protocol : native.secure; - let protocol: 'http' | 'https'; - if (protocolVal === true || protocolVal === 'true') { - protocol = 'https'; - } else if (protocolVal === false || protocolVal === 'false' || !protocolVal) { - protocol = 'http'; - } else { - protocol = protocolVal.toString().replace(/:$/, '') as 'http' | 'https'; - } - - if (_urls.length) { - _urls.forEach(item => (item.url = item.url.replace('%protocol%', protocol))); - } else { - link = link.replace('%protocol%', protocol); - } - } else if (placeholder === '%instance%') { - link = link.replace('%instance%', instance.toString()); - if (_urls.length) { - _urls.forEach(item => (item.url = item.url.replace('%instance%', instance.toString()))); - } else { - link = link.replace('%instance%', instance.toString()); - } - } else { - // remove %% - placeholder = placeholder.replace(/%/g, ''); - - if (placeholder.startsWith('native_')) { - placeholder = placeholder.substring(7); - } - - // like web.0_port or web_protocol - if (!placeholder.includes('_')) { - // if only one instance - const adapterInstance = `${adapter}.${instance}`; - if (_urls.length) { - _urls.forEach( - item => - (item.url = AdminUtils._replaceLink( - item.url, - context.instances, - adapterInstance, - placeholder, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - )), - ); - } else { - link = AdminUtils._replaceLink( - link, - context.instances, - adapterInstance, - placeholder, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - ); - port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port; - } - } else { - const [adapterInstance, attr] = placeholder.split('_'); - - // if instance number not found - if (!adapterInstance.match(/\.[0-9]+$/)) { - // list all possible instances - let ids: string[]; - if (adapter === adapterInstance) { - // take only this one instance and that's all - ids = [`${adapter}.${instance}`]; - } else { - ids = Object.keys(context.instances) - .filter( - id => - id.startsWith(`system.adapter.${adapterInstance}.`) && - context.instances[id].common.enabled, - ) - .map(id => id.substring(15)); - - // try to get disabled instances - if (!ids.length) { - ids = Object.keys(context.instances) - .filter(id => id.startsWith(`system.adapter.${adapterInstance}.`)) - .map(id => id.substring(15)); - } - } - - for (const id of ids) { - if (_urls.length) { - const item = _urls.find(t => t.instance === id); - if (item) { - item.url = AdminUtils._replaceLink( - item.url, - context.instances, - id, - attr, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - ); - } else { - // add new - const _link = AdminUtils._replaceLink( - link, - context.instances, - id, - attr, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - ); - const _port: number = context.instances[`system.adapter.${id}`]?.native - ?.port as number; - _urls.push({ url: _link, port: _port, instance: id }); - } - } else { - const _link = AdminUtils._replaceLink( - link, - context.instances, - id, - attr, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - ); - const _port: number = context.instances[`system.adapter.${id}`]?.native - ?.port as number; - _urls.push({ url: _link, port: _port, instance: id }); - } - } - } else { - link = AdminUtils._replaceLink( - link, - context.instances, - adapterInstance, - attr, - placeholder, - context.hosts, - context.hostname, - context.adminInstance, - ); - port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port as number; - } - } - } - } - } - } - - if (_urls.length) { - return _urls; - } - return [{ url: link, port }]; - } - - static objectMap( - object: Record, - callback: (res: Value, key: string) => Result, - ): Result[] { - const result: Result[] = []; - for (const key in object) { - result.push(callback(object[key], key)); - } - return result; - } - - static fixAdminUI(obj: Record): void { - if (obj?.common) { - if (!obj.common.adminUI) { - if (obj.common.noConfig) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.config = 'none'; - } else if (obj.common.jsonConfig) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.config = 'json'; - } else if (obj.common.materialize) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.config = 'materialize'; - } else { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.config = 'html'; - } - - if (obj.common.jsonCustom) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.custom = 'json'; - } else if (obj.common.supportCustoms) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.custom = 'json'; - } - - if (obj.common.materializeTab && obj.common.adminTab) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.tab = 'materialize'; - } else if (obj.common.adminTab) { - obj.common.adminUI = obj.common.adminUI || {}; - obj.common.adminUI.tab = 'html'; - } - - if (obj.common.adminUI) { - console.warn( - `Please add to "${obj._id.replace(/\.\d+$/, '')}" common.adminUI=${JSON.stringify(obj.common.adminUI)}`, - ); - } - } else { - let changed = false; - if (obj.common.materializeTab && obj.common.adminTab) { - if (obj.common.adminUI.tab !== 'materialize') { - obj.common.adminUI.tab = 'materialize'; - changed = true; - } - } else if (obj.common.adminTab) { - if (obj.common.adminUI.tab !== 'html' && obj.common.adminUI.tab !== 'materialize') { - obj.common.adminUI.tab = 'html'; - changed = true; - } - } - - if (obj.common.jsonCustom || obj.common.supportCustoms) { - if (obj.common.adminUI.custom !== 'json') { - obj.common.adminUI.custom = 'json'; - changed = true; - } - } - - if (obj.common.noConfig) { - if (obj.common.adminUI.config !== 'none') { - obj.common.adminUI.config = 'none'; - changed = true; - } - } else if (obj.common.jsonConfig) { - if (obj.common.adminUI.config !== 'json') { - obj.common.adminUI.config = 'json'; - changed = true; - } - obj.common.adminUI.config = 'json'; - } else if (obj.common.materialize) { - if (obj.common.adminUI.config !== 'materialize') { - if (!obj.common.adminUI.config) { - obj.common.adminUI.config = 'materialize'; - changed = true; - } - } - } else if (!obj.common.adminUI.config) { - obj.common.adminUI.config = 'html'; - changed = true; - } - if (changed) { - console.warn( - `Please modify "${obj._id.replace(/\.\d+$/, '')}" common.adminUI=${JSON.stringify(obj.common.adminUI)}`, - ); - } - } - } - } - - static parseColorMessage(text: string): string | { original: string; parts: { text: string; style: Style }[] } { - if (text && (text.includes('\u001b[') || text.includes('\u001B['))) { - // eslint-disable-next-line - let m = text.match(/\u001b\[\d+m/gi); - if (m) { - const original = text; - const result = []; - let style: Style = {}; - for (let i = 0; i < m.length; i++) { - const pos = text.indexOf(m[i]); - if (pos) { - result.push({ text: text.substring(0, pos), style: { ...style } }); - } - const code = parseInt(m[i].substring(2), 10); - if (STYLES[code]) { - Object.assign(style, STYLES[code]); - } else if (ANSI_RESET_COLOR === code) { - delete style.color; - } else if (ANSI_RESET_BG_COLOR === code) { - delete style.backgroundColor; - } else if (ANSI_RESET_BOLD === code) { - delete style.fontWeight; - } else if (ANSI_BOLD === code) { - style.fontWeight = 'bold'; - } else if (ANSI_RESET === code) { - style = {}; - } - text = text.substring(m[i].length + pos); - } - if (text) { - result.push({ text, style: { ...style } }); - } - - return { original, parts: result }; - } - return text; - } - return text; - } - - static PASSWORD_ERROR_LENGTH = - 'Password must be at least 8 characters long and have numbers, upper and lower case letters'; - - static PASSWORD_ERROR_NOT_EQUAL = 'Repeat password is not equal with password'; - - static PASSWORD_ERROR_EMPTY = 'Empty password is not allowed'; - - static PASSWORD_SET = '***********'; - - /** The languages for which docs are generated */ - static SUPPORTED_DOC_LANGUAGES: ioBroker.Languages[] = ['en', 'de', 'ru', 'zh-cn']; - - static checkPassword(password: string, passwordRepeat?: string): false | string { - password = password || ''; - passwordRepeat = passwordRepeat || ''; - if ( - password && - passwordRepeat && - password !== AdminUtils.PASSWORD_SET && - passwordRepeat !== AdminUtils.PASSWORD_SET - ) { - if (password.length < 8 || !password.match(/\d/) || !password.match(/[a-z]/) || !password.match(/[A-Z]/)) { - return AdminUtils.PASSWORD_ERROR_LENGTH; - } - if (password !== passwordRepeat) { - return AdminUtils.PASSWORD_ERROR_NOT_EQUAL; - } - return false; - } - if (password && password !== AdminUtils.PASSWORD_SET) { - if (password.length < 8 || !password.match(/\d/) || !password.match(/[a-z]/) || !password.match(/[A-Z]/)) { - return AdminUtils.PASSWORD_ERROR_LENGTH; - } - return false; - } - if (passwordRepeat && passwordRepeat !== AdminUtils.PASSWORD_SET) { - if ( - passwordRepeat.length < 8 || - !passwordRepeat.match(/\d/) || - !passwordRepeat.match(/[a-z]/) || - !passwordRepeat.match(/[A-Z]/) - ) { - return AdminUtils.PASSWORD_ERROR_LENGTH; - } - return false; - } - if (password === AdminUtils.PASSWORD_SET || passwordRepeat === AdminUtils.PASSWORD_SET) { - return false; - } - return AdminUtils.PASSWORD_ERROR_EMPTY; - } - - /** - * Get Link to adapter docs in given language - * - * @param options the adapter name without ioBroker. prefix and the language information - * @param options.adapterName the adapter name without ioBroker. prefix - * @param options.lang the language for the docs - */ - static getDocsLinkForAdapter(options: { lang: ioBroker.Languages; adapterName: string }): string { - const { adapterName } = options; - let { lang } = options; - - if (!AdminUtils.SUPPORTED_DOC_LANGUAGES.includes(lang)) { - lang = 'en'; - } - - return `https://www.iobroker.net/#${lang}/adapters/adapterref/iobroker.${adapterName}/README.md`; - } - - static updateAvailable(oldVersion: string, newVersion: string): boolean { - try { - return semver.gt(newVersion, oldVersion) === true; - } catch { - console.warn(`[ADAPTERS] Cannot compare "${newVersion}" and "${oldVersion}"`); - return false; - } - } - - static getText(word: ioBroker.StringOrTranslated, lang: ioBroker.Languages): string { - if (typeof word === 'object') { - if (!word) { - return ''; - } - return (word[lang] || word.en || '').toString(); - } - - return word ? word.toString() : ''; - } - - static clone(obj: T): T { - return JSON.parse(JSON.stringify(obj)); - } -} - -export default AdminUtils; diff --git a/packages/admin/src-admin/src/components/Intro/IntroCard.tsx b/packages/admin/src-admin/src/components/Intro/IntroCard.tsx index eaf0cce90..6d5a53a69 100644 --- a/packages/admin/src-admin/src/components/Intro/IntroCard.tsx +++ b/packages/admin/src-admin/src/components/Intro/IntroCard.tsx @@ -28,7 +28,7 @@ import { blue, grey, red } from '@mui/material/colors'; import { Utils, IconCopy as SaveIcon, type IobTheme, type Translate } from '@iobroker/adapter-react-v5'; -import AdminUtils from '../../AdminUtils'; +import AdminUtils from '../../helpers/AdminUtils'; const boxShadow = '0 2px 2px 0 rgba(0, 0, 0, .14),0 3px 1px -2px rgba(0, 0, 0, .12),0 1px 5px 0 rgba(0, 0, 0, .2)'; const boxShadowHover = '0 8px 17px 0 rgba(0, 0, 0, .2),0 6px 20px 0 rgba(0, 0, 0, .19)'; diff --git a/packages/admin/src-admin/src/helpers/AdminUtils.tsx b/packages/admin/src-admin/src/helpers/AdminUtils.tsx new file mode 100644 index 000000000..0ceedef2c --- /dev/null +++ b/packages/admin/src-admin/src/helpers/AdminUtils.tsx @@ -0,0 +1,435 @@ +import semver from 'semver'; +import { type Translate } from '@iobroker/adapter-react-v5'; + +declare module '@mui/material/Button' { + interface ButtonPropsColorOverrides { + grey: true; + } +} + +const ANSI_RESET = 0; +const ANSI_RESET_COLOR = 39; +const ANSI_RESET_BG_COLOR = 49; +const ANSI_BOLD = 1; +const ANSI_RESET_BOLD = 22; + +export interface Style { + color?: string; + backgroundColor?: string; + fontWeight?: string; +} + +const STYLES: Record = { + 30: { color: 'black' }, // ANSI_BLACK + 31: { color: 'red' }, // ANSI_RED + 32: { color: 'green' }, // ANSI_GREEN + 33: { color: 'yellow' }, // ANSI_YELLOW + 34: { color: 'blue' }, // ANSI_BLUE + 35: { color: 'purple' }, // ANSI_PURPLE + 36: { color: 'cyan' }, // ANSI_CYAN + 37: { color: 'white' }, // ANSI_WHITE + + 90: { color: 'grey' }, // ANSI_BRIGHT_BLACK + 91: { color: 'lightred' }, // ANSI_BRIGHT_RED + 92: { color: 'lightgreen' }, // ANSI_BRIGHT_GREEN + 93: { color: 'lightyellow' }, // ANSI_BRIGHT_YELLOW + 94: { color: 'lightblue' }, // ANSI_BRIGHT_BLUE + 95: { color: 'lightpurple' }, // ANSI_BRIGHT_PURPLE + 96: { color: 'lightcyan' }, // ANSI_BRIGHT_CYAN + 97: { color: 'white' }, // ANSI_BRIGHT_WHITE + + 40: { backgroundColor: 'black' }, // ANSI_BG_BLACK + 41: { backgroundColor: 'red' }, // ANSI_BG_RED + 42: { backgroundColor: 'green' }, // ANSI_BG_GREEN + 43: { backgroundColor: 'yellow' }, // ANSI_BG_YELLOW + 44: { backgroundColor: 'blue' }, // ANSI_BG_BLUE + 45: { backgroundColor: 'purple' }, // ANSI_BG_PURPLE + 46: { backgroundColor: 'cyan' }, // ANSI_BG_CYAN + 47: { backgroundColor: 'white' }, // ANSI_BG_WHITE + + 100: { backgroundColor: 'grey' }, // ANSI_BRIGHT_BG_BLACK + 101: { backgroundColor: 'lightred' }, // ANSI_BRIGHT_BG_RED + 102: { backgroundColor: 'lightgreen' }, // ANSI_BRIGHT_BG_GREEN + 103: { backgroundColor: 'lightyellow' }, // ANSI_BRIGHT_BG_YELLOW + 104: { backgroundColor: 'lightblue' }, // ANSI_BRIGHT_BG_BLUE + 105: { backgroundColor: 'lightpurple' }, // ANSI_BRIGHT_BG_PURPLE + 106: { backgroundColor: 'lightcyan' }, // ANSI_BRIGHT_BG_CYAN + 107: { backgroundColor: 'white' }, // ANSI_BRIGHT_BG_WHITE +}; + +class AdminUtils { + /** + * Perform JSON parse/stringify with type inference + * + * @param obj the object to clone + */ + static deepClone>(obj: T): T { + return JSON.parse(JSON.stringify(obj)); + } + + /** + * Format bytes to MB or GB + * + * @param bytes the number of bytes + */ + static formatRam(bytes: number): string { + const GB = Math.floor((bytes / (1024 * 1024 * 1024)) * 10) / 10; + bytes %= 1024 * 1024 * 1024; + const MB = Math.floor((bytes / (1024 * 1024)) * 10) / 10; + let text = ''; + + if (GB > 1) { + text += `${GB} GB`; + } else { + text += `${MB} MB`; + } + + return text; + } + + static isTouchDevice(): boolean { + return 'ontouchstart' in window || window.navigator.maxTouchPoints > 0 || window.navigator.msMaxTouchPoints > 0; + } + + static formatSpeed(mhz: number): string { + return `${mhz} MHz`; + } + + static formatBytes(bytes: number): string { + if (Math.abs(bytes) < 1024) { + return `${bytes} B`; + } + + const units = ['KB', 'MB', 'GB']; + // const units = ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB']; + let u = -1; + + do { + bytes /= 1024; + ++u; + } while (Math.abs(bytes) >= 1024 && u < units.length - 1); + + return `${bytes.toFixed(1)} ${units[u]}`; + } + + static getFileExtension(fileName: string): string | null { + const pos = fileName.lastIndexOf('.'); + if (pos !== -1) { + return fileName.substring(pos + 1).toLowerCase(); + } + return null; + } + + // Big thanks to: https://stackoverflow.com/questions/35969656/how-can-i-generate-the-opposite-color-according-to-current-color + static invertColor(hex: string, bw: boolean): string { + if (hex === undefined || hex === null || hex === '' || typeof hex !== 'string') { + return ''; + } + if (hex.indexOf('#') === 0) { + hex = hex.slice(1); + } + // convert 3-digit hex to 6-digits. + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + if (hex.length !== 6) { + throw new Error('Invalid HEX color.'); + } + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + + if (bw) { + // http://stackoverflow.com/a/3943023/112731 + return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#FFFFFF'; + } + // invert color components + const finalR = (255 - r).toString(16); + const finalG = (255 - g).toString(16); + const finalB = (255 - b).toString(16); + // pad each with zeros and return + return `#${finalR.padStart(2, '0')}${finalG.padStart(2, '0')}${finalB.padStart(2, '0')}`; + } + + /** + * Format number in seconds to time text + * + * @param seconds the number of seconds + * @param t i18n.t function + */ + static formatSeconds(seconds: number, t: Translate): string { + const days = Math.floor(seconds / (3600 * 24)); + let minutesRes: string; + let secondsRes: string; + let hoursRes: string; + + seconds %= 3600 * 24; + const hours = Math.floor(seconds / 3600); + + if (hours < 10) { + hoursRes = `0${hours}`; + } else { + hoursRes = hours.toString(); + } + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + if (minutes < 10) { + minutesRes = `0${minutes}`; + } else { + minutesRes = minutes.toString(); + } + + seconds %= 60; + seconds = Math.floor(seconds); + if (seconds < 10) { + secondsRes = `0${seconds}`; + } else { + secondsRes = seconds.toString(); + } + + let text = ''; + if (days) { + text += `${days} ${t('daysShortText')} `; + } + text += `${hoursRes}:${minutesRes}:${secondsRes}`; + + return text; + } + + static objectMap( + object: Record, + callback: (res: Value, key: string) => Result, + ): Result[] { + const result: Result[] = []; + for (const key in object) { + result.push(callback(object[key], key)); + } + return result; + } + + static fixAdminUI(obj: Record): void { + if (obj?.common) { + if (!obj.common.adminUI) { + if (obj.common.noConfig) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.config = 'none'; + } else if (obj.common.jsonConfig) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.config = 'json'; + } else if (obj.common.materialize) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.config = 'materialize'; + } else { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.config = 'html'; + } + + if (obj.common.jsonCustom) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.custom = 'json'; + } else if (obj.common.supportCustoms) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.custom = 'json'; + } + + if (obj.common.materializeTab && obj.common.adminTab) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.tab = 'materialize'; + } else if (obj.common.adminTab) { + obj.common.adminUI = obj.common.adminUI || {}; + obj.common.adminUI.tab = 'html'; + } + + if (obj.common.adminUI) { + console.warn( + `Please add to "${obj._id.replace(/\.\d+$/, '')}" common.adminUI=${JSON.stringify(obj.common.adminUI)}`, + ); + } + } else { + let changed = false; + if (obj.common.materializeTab && obj.common.adminTab) { + if (obj.common.adminUI.tab !== 'materialize') { + obj.common.adminUI.tab = 'materialize'; + changed = true; + } + } else if (obj.common.adminTab) { + if (obj.common.adminUI.tab !== 'html' && obj.common.adminUI.tab !== 'materialize') { + obj.common.adminUI.tab = 'html'; + changed = true; + } + } + + if (obj.common.jsonCustom || obj.common.supportCustoms) { + if (obj.common.adminUI.custom !== 'json') { + obj.common.adminUI.custom = 'json'; + changed = true; + } + } + + if (obj.common.noConfig) { + if (obj.common.adminUI.config !== 'none') { + obj.common.adminUI.config = 'none'; + changed = true; + } + } else if (obj.common.jsonConfig) { + if (obj.common.adminUI.config !== 'json') { + obj.common.adminUI.config = 'json'; + changed = true; + } + obj.common.adminUI.config = 'json'; + } else if (obj.common.materialize) { + if (obj.common.adminUI.config !== 'materialize') { + if (!obj.common.adminUI.config) { + obj.common.adminUI.config = 'materialize'; + changed = true; + } + } + } else if (!obj.common.adminUI.config) { + obj.common.adminUI.config = 'html'; + changed = true; + } + if (changed) { + console.warn( + `Please modify "${obj._id.replace(/\.\d+$/, '')}" common.adminUI=${JSON.stringify(obj.common.adminUI)}`, + ); + } + } + } + } + + static parseColorMessage(text: string): string | { original: string; parts: { text: string; style: Style }[] } { + if (text && (text.includes('\u001b[') || text.includes('\u001B['))) { + // eslint-disable-next-line + let m = text.match(/\u001b\[\d+m/gi); + if (m) { + const original = text; + const result = []; + let style: Style = {}; + for (let i = 0; i < m.length; i++) { + const pos = text.indexOf(m[i]); + if (pos) { + result.push({ text: text.substring(0, pos), style: { ...style } }); + } + const code = parseInt(m[i].substring(2), 10); + if (STYLES[code]) { + Object.assign(style, STYLES[code]); + } else if (ANSI_RESET_COLOR === code) { + delete style.color; + } else if (ANSI_RESET_BG_COLOR === code) { + delete style.backgroundColor; + } else if (ANSI_RESET_BOLD === code) { + delete style.fontWeight; + } else if (ANSI_BOLD === code) { + style.fontWeight = 'bold'; + } else if (ANSI_RESET === code) { + style = {}; + } + text = text.substring(m[i].length + pos); + } + if (text) { + result.push({ text, style: { ...style } }); + } + + return { original, parts: result }; + } + return text; + } + return text; + } + + static PASSWORD_ERROR_LENGTH = + 'Password must be at least 8 characters long and have numbers, upper and lower case letters'; + + static PASSWORD_ERROR_NOT_EQUAL = 'Repeat password is not equal with password'; + + static PASSWORD_ERROR_EMPTY = 'Empty password is not allowed'; + + static PASSWORD_SET = '***********'; + + /** The languages for which docs are generated */ + static SUPPORTED_DOC_LANGUAGES: ioBroker.Languages[] = ['en', 'de', 'ru', 'zh-cn']; + + static checkPassword(password: string, passwordRepeat?: string): false | string { + password = password || ''; + passwordRepeat = passwordRepeat || ''; + if ( + password && + passwordRepeat && + password !== AdminUtils.PASSWORD_SET && + passwordRepeat !== AdminUtils.PASSWORD_SET + ) { + if (password.length < 8 || !password.match(/\d/) || !password.match(/[a-z]/) || !password.match(/[A-Z]/)) { + return AdminUtils.PASSWORD_ERROR_LENGTH; + } + if (password !== passwordRepeat) { + return AdminUtils.PASSWORD_ERROR_NOT_EQUAL; + } + return false; + } + if (password && password !== AdminUtils.PASSWORD_SET) { + if (password.length < 8 || !password.match(/\d/) || !password.match(/[a-z]/) || !password.match(/[A-Z]/)) { + return AdminUtils.PASSWORD_ERROR_LENGTH; + } + return false; + } + if (passwordRepeat && passwordRepeat !== AdminUtils.PASSWORD_SET) { + if ( + passwordRepeat.length < 8 || + !passwordRepeat.match(/\d/) || + !passwordRepeat.match(/[a-z]/) || + !passwordRepeat.match(/[A-Z]/) + ) { + return AdminUtils.PASSWORD_ERROR_LENGTH; + } + return false; + } + if (password === AdminUtils.PASSWORD_SET || passwordRepeat === AdminUtils.PASSWORD_SET) { + return false; + } + return AdminUtils.PASSWORD_ERROR_EMPTY; + } + + /** + * Get Link to adapter docs in given language + * + * @param options the adapter name without ioBroker. prefix and the language information + * @param options.adapterName the adapter name without ioBroker. prefix + * @param options.lang the language for the docs + */ + static getDocsLinkForAdapter(options: { lang: ioBroker.Languages; adapterName: string }): string { + const { adapterName } = options; + let { lang } = options; + + if (!AdminUtils.SUPPORTED_DOC_LANGUAGES.includes(lang)) { + lang = 'en'; + } + + return `https://www.iobroker.net/#${lang}/adapters/adapterref/iobroker.${adapterName}/README.md`; + } + + static updateAvailable(oldVersion: string, newVersion: string): boolean { + try { + return semver.gt(newVersion, oldVersion) === true; + } catch { + console.warn(`[ADAPTERS] Cannot compare "${newVersion}" and "${oldVersion}"`); + return false; + } + } + + static getText(word: ioBroker.StringOrTranslated, lang: ioBroker.Languages): string { + if (typeof word === 'object') { + if (!word) { + return ''; + } + return (word[lang] || word.en || '').toString(); + } + + return word ? word.toString() : ''; + } + + static clone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); + } +} + +export default AdminUtils; diff --git a/packages/admin/src-admin/src/helpers/utils.ts b/packages/admin/src-admin/src/helpers/utils.ts index 20347c13a..6d63ef3c2 100644 --- a/packages/admin/src-admin/src/helpers/utils.ts +++ b/packages/admin/src-admin/src/helpers/utils.ts @@ -1,22 +1,3 @@ -/** - * Tests whether the given variable is a real object and not an Array - * - * @param it The variable to test - * @returns true if it is Record - */ -export function isObject(it: any): it is Record { - // This is necessary because: - // typeof null === 'object' - // typeof [] === 'object' - // [] instanceof Object === true - return Object.prototype.toString.call(it) === '[object Object]'; // this code is 25% faster than below one - // return it && typeof it === 'object' && !(it instanceof Array); -} - -export function isTouchDevice(): boolean { - return 'ontouchstart' in window || window.navigator.maxTouchPoints > 0 || window.navigator.msMaxTouchPoints > 0; -} - /** Url where controller changelog is reachable */ export const CONTROLLER_CHANGELOG_URL = 'https://github.com/ioBroker/ioBroker.js-controller/blob/master/CHANGELOG.md'; @@ -30,3 +11,430 @@ export const AUTO_UPGRADE_OPTIONS_MAPPING: Record (ipInt << 8) + parseInt(octet, 10), 0) >>> 0; +} + +function findNetworkAddressOfHost(obj: ioBroker.HostObject, localIp: string): null | string { + const networkInterfaces = obj?.native?.hardware?.networkInterfaces; + if (!networkInterfaces) { + return null; + } + + let hostIp; + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + if (localIp.includes(':') && ip.family !== 'IPv6') { + return; + } + if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { + return; + } + if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { + // if DNS name + hostIp = ip.address; + } else if ( + ip.family === 'IPv4' && + localIp.includes('.') && + (ip2int(localIp) & ip2int(ip.netmask)) === (ip2int(ip.address) & ip2int(ip.netmask)) + ) { + hostIp = ip.address; + } else { + hostIp = ip.address; + } + } + } + + if (!hostIp) { + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + if (localIp.includes(':') && ip.family !== 'IPv6') { + return; + } + if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { + return; + } + if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { + // if DNS name + hostIp = ip.address; + } else { + hostIp = ip.address; + } + } + } + } + + if (!hostIp) { + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + hostIp = ip.address; + } + } + } + + return hostIp; +} + +function getHostname( + instanceObj: ioBroker.InstanceObject, + objects: Record, + hosts: Record, + currentHostname: string, + adminInstance: string, +): string { + if (!instanceObj || !instanceObj.common) { + return null; + } + + let hostname; + // check if the adapter from the same host as admin + const adminHost = objects[`system.adapter.${adminInstance}`]?.common?.host; + if (instanceObj.common.host !== adminHost) { + // find IP address + const host = hosts[`system.host.${instanceObj.common.host}`]; + if (host) { + const ip = findNetworkAddressOfHost(host, currentHostname); + if (ip) { + hostname = ip; + } else { + console.warn(`Cannot find suitable IP in host ${instanceObj.common.host} for ${instanceObj._id}`); + return null; + } + } else { + console.warn(`Cannot find host ${instanceObj.common.host} for ${instanceObj._id}`); + return null; + } + } else { + hostname = currentHostname; + } + + return hostname; +} + +// internal use +function _replaceLink( + link: string, + objects: Record, + adapterInstance: string, + attr: string, + placeholder: string, + hosts: Record, + hostname: string, + adminInstance: string, +): string { + if (attr === 'protocol') { + attr = 'secure'; + } + + try { + const object = objects[`system.adapter.${adapterInstance}`]; + + if (link && object) { + if (attr === 'secure') { + link = link.replace(`%${placeholder}%`, object.native[attr] ? 'https' : 'http'); + } else { + let value = object.native[attr]; + // workaround for port + if ((attr === 'webinterfacePort' || attr === 'port') && (!value || value === '0')) { + if (object.native.secure === true) { + value = 443; + } else { + value = 80; + } + } + + if (attr === 'bind' || attr === 'ip') { + let ip = object.native.bind || object.native.ip; + if (ip === '0.0.0.0') { + ip = getHostname(object, objects, hosts, hostname, adminInstance); + } + if (!link.includes(`%${placeholder}%`)) { + link = link.replace(`%native_${placeholder}%`, ip || ''); + } else { + link = link.replace(`%${placeholder}%`, ip || ''); + } + } else if (!link.includes(`%${placeholder}%`)) { + link = link.replace(`%native_${placeholder}%`, value); + } else { + link = link.replace(`%${placeholder}%`, value); + } + } + } else { + console.log(`Cannot get link ${attr}`); + link = link.replace(`%${placeholder}%`, ''); + } + } catch (error) { + console.log(error); + } + return link; +} + +/** + * Convert the template link to string + * + * Possible placeholders: + * `%ip%` - `native.bind` or `native.ip` of this adapter. If it is '0.0.0.0', we are trying to find the host IP that is reachable from the current browser. + * `%protocol%` - `native.protocol` or `native.secure` of this adapter. The result is 'http' or 'https'. + * `%s%` - `native.protocol` or `native.secure` of this adapter. The result is '' or 's'. The idea is to use the pattern like "http%s%://..." + * `%instance%` - instance number + * `%common.attr%` - instance number + * + * @param link pattern for link + * @param adapter adapter name + * @param instance adapter instance number + * @param context Context object + * @param context.instances Object with all instances + * @param context.hostname Actual host name + * @param context.adminInstance Actual admin instance + * @param context.hosts Object with all hosts + */ +export function replaceLink( + link: string, + adapter: string, + instance: number, + context: { + instances: Record; + hostname: string; + adminInstance: string; + hosts: Record; + }, +): { + url: string; + port: number; + instance?: string; +}[] { + const _urls: { + url: string; + port: number; + instance?: string; + }[] = []; + let port: number; + + if (link) { + const instanceObj = context.instances[`system.adapter.${adapter}.${instance}`]; + const native = instanceObj?.native || {}; + + const placeholders = link.match(/%(\w+)%/g); + + if (placeholders) { + for (let p = 0; p < placeholders.length; p++) { + let placeholder = placeholders[p]; + + if (placeholder === '%ip%') { + let ip: string = (native.bind || native.ip) as string; + if (!ip || ip === '0.0.0.0') { + // Check host + ip = getHostname( + instanceObj, + context.instances, + context.hosts, + context.hostname, + context.adminInstance, + ); + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%ip%', ip))); + } else { + link = link.replace('%ip%', ip || ''); + } + } else if (placeholder === '%protocol%') { + const protocolVal: string | boolean = native.secure === undefined ? native.protocol : native.secure; + let protocol: 'http' | 'https'; + if (protocolVal === true || protocolVal === 'true') { + protocol = 'https'; + } else if (protocolVal === false || protocolVal === 'false' || !protocolVal) { + protocol = 'http'; + } else { + protocol = protocolVal.toString().replace(/:$/, '') as 'http' | 'https'; + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%protocol%', protocol))); + } else { + link = link.replace('%protocol%', protocol); + } + } else if (placeholder === '%s%') { + const protocolVal: string | boolean = native.secure === undefined ? native.protocol : native.secure; + let protocol: '' | 's'; + if (protocolVal === true || protocolVal === 'true') { + protocol = 's'; + } else if (protocolVal === false || protocolVal === 'false' || !protocolVal) { + protocol = ''; + } else { + protocol = protocolVal.toString().replace(/:$/, '') as '' | 's'; + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%s%', protocol))); + } else { + link = link.replace('%s%', protocol); + } + } else if (placeholder === '%instance%') { + link = link.replace('%instance%', instance.toString()); + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%instance%', instance.toString()))); + } else { + link = link.replace('%instance%', instance.toString()); + } + } else { + // remove %% + placeholder = placeholder.replace(/%/g, ''); + + if (placeholder.startsWith('native_')) { + placeholder = placeholder.substring(7); + } + + // like web.0_port or web_protocol + if (!placeholder.includes('_')) { + // if only one instance + const adapterInstance = `${adapter}.${instance}`; + if (_urls.length) { + _urls.forEach( + item => + (item.url = _replaceLink( + item.url, + context.instances, + adapterInstance, + placeholder, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + )), + ); + } else { + link = _replaceLink( + link, + context.instances, + adapterInstance, + placeholder, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port; + } + } else { + const [adapterInstance, attr] = placeholder.split('_'); + + // if instance number not found + if (!adapterInstance.match(/\.[0-9]+$/)) { + // list all possible instances + let ids: string[]; + if (adapter === adapterInstance) { + // take only this one instance and that's all + ids = [`${adapter}.${instance}`]; + } else { + ids = Object.keys(context.instances) + .filter( + id => + id.startsWith(`system.adapter.${adapterInstance}.`) && + context.instances[id].common.enabled, + ) + .map(id => id.substring(15)); + + // try to get disabled instances + if (!ids.length) { + ids = Object.keys(context.instances) + .filter(id => id.startsWith(`system.adapter.${adapterInstance}.`)) + .map(id => id.substring(15)); + } + } + + for (const id of ids) { + if (_urls.length) { + const item = _urls.find(t => t.instance === id); + if (item) { + item.url = _replaceLink( + item.url, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + } else { + // add new + const _link = _replaceLink( + link, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + const _port: number = context.instances[`system.adapter.${id}`]?.native + ?.port as number; + + _urls.push({ url: _link, port: _port, instance: id }); + } + } else { + const _link = _replaceLink( + link, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + + const _port: number = context.instances[`system.adapter.${id}`]?.native + ?.port as number; + _urls.push({ url: _link, port: _port, instance: id }); + } + } + } else { + link = _replaceLink( + link, + context.instances, + adapterInstance, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + + port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port as number; + } + } + } + } + } + } + + if (_urls.length) { + return _urls; + } + return [{ url: link, port }]; +} diff --git a/packages/admin/src-admin/src/tabs/Intro.tsx b/packages/admin/src-admin/src/tabs/Intro.tsx index 2a37142de..bf6d2b344 100644 --- a/packages/admin/src-admin/src/tabs/Intro.tsx +++ b/packages/admin/src-admin/src/tabs/Intro.tsx @@ -24,7 +24,8 @@ import { import type InstancesWorker from '@/Workers/InstancesWorker'; import type HostsWorker from '@/Workers/HostsWorker'; import { type HostAliveEvent, type HostEvent } from '@/Workers/HostsWorker'; -import AdminUtils from '@/AdminUtils'; +import AdminUtils from '@/helpers/AdminUtils'; +import { replaceLink } from '@/helpers/utils'; import IntroCard from '@/components/Intro/IntroCard'; import EditIntroLinkDialog from '@/components/Intro/EditIntroLinkDialog'; @@ -32,6 +33,20 @@ import { type InstanceEvent } from '@/Workers/InstancesWorker'; import NodeUpdateDialog from '@/dialogs/NodeUpdateDialog'; import IntroCardCamera from '@/components/Intro/IntroCardCamera'; +type OldLinkStructure = { + link: string; + color?: string; + order?: number | string; + icon?: string; + img?: string; + description?: ioBroker.StringOrTranslated; + pro?: string | boolean; + cloud?: string; + intro?: boolean; + name?: ioBroker.StringOrTranslated; + localLinks?: string; +}; + export type CompactHost = { _id: `system.host.${string}`; common: { @@ -834,7 +849,7 @@ class Intro extends React.Component { url: string; port: number; instance?: string; - }[] = AdminUtils.replaceLink(linkItem.link, common.name, instanceId, { + }[] = replaceLink(linkItem.link, common.name, instanceId, { instances, hostname: this.props.hostname, adminInstance: this.props.adminInstance, @@ -900,7 +915,8 @@ class Intro extends React.Component { id: instance._id.replace('system.adapter.', ''), link: '', name: Intro.getText(instance.common.titleLang || instance.common.title || instance.common.name, language), - order: 1000, + // @ts-expect-error order in common is deprecated, but could happen + order: instance.common.order || 1000, intro: true, description: Intro.getText(instance.common.desc, language), icon: instance.common.icon ? `adapter/${instance.common.name}/${instance.common.icon}` : 'img/no-image.png', @@ -913,10 +929,16 @@ class Intro extends React.Component { link: instance.common.localLink, }); } else { - const compatibilityStructure: Record = instance.common.localLink as unknown as Record< - string, - any - >; + // localLink is an object + /* + { + link: string; + color: string; + order: number; + icon: string; + } + */ + const compatibilityStructure: OldLinkStructure = instance.common.localLink as OldLinkStructure; if (compatibilityStructure.link) { const item: LinkItem = { ...defaultLink, @@ -951,7 +973,7 @@ class Intro extends React.Component { link: linkItem, }); } else { - const compatibilityStructure: Record = linkItem as Record; + const compatibilityStructure: OldLinkStructure = linkItem as OldLinkStructure; if (compatibilityStructure.link) { const item: LinkItem = { @@ -963,19 +985,16 @@ class Intro extends React.Component { name: defaultLink.name + (linkName === '_default' ? '' : ` ${linkName}`), }; if (compatibilityStructure.color) { - item.color = compatibilityStructure.color as string; + item.color = compatibilityStructure.color; } if (compatibilityStructure.order !== undefined) { item.order = parseInt(compatibilityStructure.order as string, 10) || 1000; } if (compatibilityStructure.icon && compatibilityStructure.img) { - item.icon = (compatibilityStructure.icon || compatibilityStructure.img) as string; + item.icon = compatibilityStructure.icon || compatibilityStructure.img; } if (compatibilityStructure.description) { - item.description = Intro.getText( - compatibilityStructure.description as ioBroker.StringOrTranslated, - language, - ); + item.description = Intro.getText(compatibilityStructure.description, language); } if (compatibilityStructure.pro !== undefined) { if (typeof compatibilityStructure.pro === 'string') { @@ -985,17 +1004,14 @@ class Intro extends React.Component { } } if (compatibilityStructure.cloud !== undefined) { - item.cloud = compatibilityStructure.cloud as string; + item.cloud = compatibilityStructure.cloud; } if (compatibilityStructure.intro !== undefined) { item.intro = compatibilityStructure.intro === true; } if (compatibilityStructure.name) { - item.name = Intro.getText( - compatibilityStructure.name as ioBroker.StringOrTranslated, - language, - ); + item.name = Intro.getText(compatibilityStructure.name, language); } if (linkName === '_default') { item.default = true; @@ -1010,9 +1026,10 @@ class Intro extends React.Component { } if (instance.common.welcomeScreen && typeof instance.common.welcomeScreen === 'object') { - const compatibilityStructureArr: Record[] = Array.isArray(instance.common.welcomeScreen) - ? (instance.common.welcomeScreen as Record[]) - : [instance.common.welcomeScreen as Record]; + const compatibilityStructureArr: OldLinkStructure[] = Array.isArray(instance.common.welcomeScreen) + ? (instance.common.welcomeScreen as OldLinkStructure[]) + : [instance.common.welcomeScreen as OldLinkStructure]; + compatibilityStructureArr.forEach(compatibilityStructure => { if (compatibilityStructure.link) { const item: LinkItem = { @@ -1044,7 +1061,7 @@ class Intro extends React.Component { } if (compatibilityStructure.name) { - item.name = Intro.getText(compatibilityStructure.name as ioBroker.StringOrTranslated, language); + item.name = Intro.getText(compatibilityStructure.name, language); } result.push(item); @@ -1052,11 +1069,9 @@ class Intro extends React.Component { }); if (instance.common.welcomeScreenPro && typeof instance.common.welcomeScreenPro === 'object') { - const _compatibilityStructureArr: Record[] = Array.isArray( - instance.common.welcomeScreenPro, - ) - ? (instance.common.welcomeScreenPro as Record[]) - : [instance.common.welcomeScreenPro as Record]; + const _compatibilityStructureArr: OldLinkStructure[] = Array.isArray(instance.common.welcomeScreenPro) + ? (instance.common.welcomeScreenPro as OldLinkStructure[]) + : [instance.common.welcomeScreenPro as OldLinkStructure]; _compatibilityStructureArr.forEach(compatibilityStructure => { if (compatibilityStructure.link) { @@ -1089,10 +1104,7 @@ class Intro extends React.Component { } if (compatibilityStructure.name) { - item.name = Intro.getText( - compatibilityStructure.name as ioBroker.StringOrTranslated, - language, - ); + item.name = Intro.getText(compatibilityStructure.name, language); } result.push(item); @@ -1112,7 +1124,6 @@ class Intro extends React.Component { // normalize icon item.icon = `adapter/${instance.common.name}/${item.icon}`; } - item.link = item.link.replace(/%ip%/g, '%web_bind%'); }); if (filterDuplicates) { @@ -1206,76 +1217,9 @@ class Intro extends React.Component { } const introInstances: IntroInstanceItem[] = []; const instances: Record = {}; + // Array to the mapped object objects.forEach(obj => (instances[obj._id] = obj)); - objects.sort((_a, _b) => { - const a: Partial = _a?.common ?? {}; - const b: Partial = _b?.common ?? {}; - - // @ts-expect-error need to be added to types if this can exist - if (a.order === undefined && b.order === undefined) { - let aName; - let bName; - if (typeof a.name === 'object') { - const commonNameA: ioBroker.Translated = a.name; - aName = commonNameA[this.props.lang] || commonNameA.en; - } else { - aName = a.name || ''; - } - if (typeof b.name === 'object') { - const commonNameB: ioBroker.Translated = b.name; - bName = commonNameB[this.props.lang] || commonNameB.en; - } else { - bName = b.name || ''; - } - if (aName.toLowerCase() > bName.toLowerCase()) { - return 1; - } - if (aName.toLowerCase() < bName.toLowerCase()) { - return -1; - } - return 0; - } - // @ts-expect-error need to be added to types if this can exist - if (a.order === undefined) { - return -1; - } - // @ts-expect-error need to be added to types if this can exist - if (b.order === undefined) { - return 1; - } - // @ts-expect-error need to be added to types if this can exist - if (a.order > b.order) { - return 1; - } - // @ts-expect-error need to be added to types if this can exist - if (a.order < b.order) { - return -1; - } - let aName: string; - if (typeof a.name === 'object') { - const commonNameA: ioBroker.Translated = a.name; - aName = commonNameA[this.props.lang] || commonNameA.en; - } else { - aName = a.name || ''; - } - - let bName; - if (typeof b.name === 'object') { - const commonNameB: ioBroker.Translated = b.name; - bName = commonNameB[this.props.lang] || commonNameB.en; - } else { - bName = b.name || ''; - } - if (aName.toLowerCase() > bName.toLowerCase()) { - return 1; - } - if (aName.toLowerCase() < bName.toLowerCase()) { - return -1; - } - return 0; - }); - objects.forEach(obj => { if (!obj) { return; @@ -1301,7 +1245,7 @@ class Intro extends React.Component { if (name && name !== 'vis-web-admin' && name.match(/^vis-/) && name !== 'vis-2') { return; } - if (name && name.match(/^icons-/)) { + if (name?.match(/^icons-/)) { return; } if (common && (common.enabled || common.onlyWWW)) { diff --git a/packages/admin/src/lib/utils.ts b/packages/admin/src/lib/utils.ts new file mode 100644 index 000000000..6504ee8b6 --- /dev/null +++ b/packages/admin/src/lib/utils.ts @@ -0,0 +1,441 @@ +/** Url where controller changelog is reachable */ +export const CONTROLLER_CHANGELOG_URL = 'https://github.com/ioBroker/ioBroker.js-controller/blob/master/CHANGELOG.md'; + +/** All possible auto upgrade settings */ +export const AUTO_UPGRADE_SETTINGS: ioBroker.AutoUpgradePolicy[] = ['none', 'patch', 'minor', 'major']; + +/** Mapping to make it more understandable which upgrades are allowed */ +export const AUTO_UPGRADE_OPTIONS_MAPPING: Record = { + none: 'none', + patch: 'patch', + minor: 'patch & minor', + major: 'patch, minor & major', +}; + +function ip2int(ip: string): number { + return ip.split('.').reduce((ipInt, octet) => (ipInt << 8) + parseInt(octet, 10), 0) >>> 0; +} + +function findNetworkAddressOfHost(obj: ioBroker.HostObject, localIp: string): null | string { + const networkInterfaces = obj?.native?.hardware?.networkInterfaces; + if (!networkInterfaces) { + return null; + } + + let hostIp; + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + if (localIp.includes(':') && ip.family !== 'IPv6') { + return; + } + if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { + return; + } + if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { + // if DNS name + hostIp = ip.address; + } else if ( + ip.family === 'IPv4' && + localIp.includes('.') && + (ip2int(localIp) & ip2int(ip.netmask)) === (ip2int(ip.address) & ip2int(ip.netmask)) + ) { + hostIp = ip.address; + } else { + hostIp = ip.address; + } + } + } + + if (!hostIp) { + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + if (localIp.includes(':') && ip.family !== 'IPv6') { + return; + } + if (localIp.includes('.') && !localIp.match(/[^.\d]/) && ip.family !== 'IPv4') { + return; + } + if (localIp === '127.0.0.0' || localIp === 'localhost' || localIp.match(/[^.\d]/)) { + // if DNS name + hostIp = ip.address; + } else { + hostIp = ip.address; + } + } + } + } + + if (!hostIp) { + for (const networkInterface of Object.values(networkInterfaces)) { + if (!networkInterface) { + continue; + } + for (let i = 0; i < networkInterface.length; i++) { + const ip = networkInterface[i]; + if (ip.internal) { + return; + } + hostIp = ip.address; + } + } + } + + return hostIp; +} + +function getHostname( + instanceObj: ioBroker.InstanceObject, + objects: Record, + hosts: Record, + currentHostname: string, + adminInstance: string, +): string { + if (!instanceObj || !instanceObj.common) { + return null; + } + + let hostname; + // check if the adapter from the same host as admin + const adminHost = objects[`system.adapter.${adminInstance}`]?.common?.host; + if (instanceObj.common.host !== adminHost) { + // find IP address + const host = hosts[`system.host.${instanceObj.common.host}`]; + if (host) { + const ip = findNetworkAddressOfHost(host, currentHostname); + if (ip) { + hostname = ip; + } else { + console.warn(`Cannot find suitable IP in host ${instanceObj.common.host} for ${instanceObj._id}`); + return null; + } + } else { + console.warn(`Cannot find host ${instanceObj.common.host} for ${instanceObj._id}`); + return null; + } + } else { + hostname = currentHostname; + } + + return hostname; +} + +// internal use +function _replaceLink( + link: string, + objects: Record, + adapterInstance: string, + attr: string, + placeholder: string, + hosts: Record, + hostname: string, + adminInstance: string, +): string { + if (attr === 'protocol') { + attr = 'secure'; + } + + try { + const object = objects[`system.adapter.${adapterInstance}`]; + + if (link && object) { + if (attr === 'secure') { + link = link.replace(`%${placeholder}%`, object.native[attr] ? 'https' : 'http'); + } else { + let value = object.native[attr]; + // workaround for port + if ((attr === 'webinterfacePort' || attr === 'port') && (!value || value === '0')) { + if (object.native.secure === true) { + value = 443; + } else { + value = 80; + } + } + + if (attr === 'bind' || attr === 'ip') { + let ip = object.native.bind || object.native.ip; + if (ip === '0.0.0.0') { + ip = getHostname(object, objects, hosts, hostname, adminInstance); + } + if (!link.includes(`%${placeholder}%`)) { + link = link.replace(`%native_${placeholder}%`, ip || ''); + } else { + link = link.replace(`%${placeholder}%`, ip || ''); + } + } else if (!link.includes(`%${placeholder}%`)) { + link = link.replace(`%native_${placeholder}%`, value); + } else { + link = link.replace(`%${placeholder}%`, value); + } + } + } else { + console.log(`Cannot get link ${attr}`); + link = link.replace(`%${placeholder}%`, ''); + } + } catch (error) { + console.log(error); + } + return link; +} + +/** + * Convert the template link to string + * + * Possible placeholders: + * `%ip%` - `native.bind` or `native.ip` of this adapter. If it is '0.0.0.0', we are trying to find the host IP that is reachable from the current browser. + * `%protocol%` - `native.protocol` or `native.secure` of this adapter. The result is 'http' or 'https'. + * `%s%` - `native.protocol` or `native.secure` of this adapter. The result is '' or 's'. The idea is to use the pattern like "http%s%://..." + * `%instance%` - instance number + * `%adapterName_nativeAttr%` - Takes the native value `nativeAttr` of all instances of adapterName. This generates many links if more than one instance installed + * `%adapterName.x_nativeAttr%` - Takes the native value `nativeAttr` of adapterName.x instance + * + * @param link pattern for link + * @param adapter adapter name + * @param instance adapter instance number + * @param context Context object + * @param context.instances Object with all instances + * @param context.hostname Actual host name + * @param context.adminInstance Actual admin instance + * @param context.hosts Object with all hosts + */ +export function replaceLink( + link: string, + adapter: string, + instance: number, + context: { + instances: Record; + hostname: string; + adminInstance: string; + hosts: Record; + }, +): { + url: string; + port: number; + instance?: string; +}[] { + const _urls: { + url: string; + port: number; + instance?: string; + }[] = []; + let port: number; + + if (link) { + const instanceObj = context.instances[`system.adapter.${adapter}.${instance}`]; + const native = instanceObj?.native || {}; + + const placeholders = link.match(/%(\w+)%/g); + + if (placeholders) { + for (let p = 0; p < placeholders.length; p++) { + let placeholder = placeholders[p]; + + if (placeholder === '%ip%') { + let ip: string = (native.bind || native.ip) as string; + if (!ip || ip === '0.0.0.0') { + // Check host + ip = getHostname( + instanceObj, + context.instances, + context.hosts, + context.hostname, + context.adminInstance, + ); + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%ip%', ip))); + } else { + link = link.replace('%ip%', ip || ''); + } + } else if (placeholder === '%protocol%') { + const protocolVal: string | boolean = native.secure === undefined ? native.protocol : native.secure; + let protocol: 'http' | 'https'; + if (protocolVal === true || protocolVal === 'true') { + protocol = 'https'; + } else if (protocolVal === false || protocolVal === 'false' || !protocolVal) { + protocol = 'http'; + } else { + protocol = protocolVal.toString().replace(/:$/, '') as 'http' | 'https'; + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%protocol%', protocol))); + } else { + link = link.replace('%protocol%', protocol); + } + } else if (placeholder === '%s%') { + const protocolVal: string | boolean = native.secure === undefined ? native.protocol : native.secure; + let protocol: '' | 's'; + if (protocolVal === true || protocolVal === 'true') { + protocol = 's'; + } else if (protocolVal === false || protocolVal === 'false' || !protocolVal) { + protocol = ''; + } else { + protocol = protocolVal.toString().replace(/:$/, '') as '' | 's'; + } + + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%s%', protocol))); + } else { + link = link.replace('%s%', protocol); + } + } else if (placeholder === '%instance%') { + link = link.replace('%instance%', instance.toString()); + if (_urls.length) { + _urls.forEach(item => (item.url = item.url.replace('%instance%', instance.toString()))); + } else { + link = link.replace('%instance%', instance.toString()); + } + } else { + // remove %% + placeholder = placeholder.replace(/%/g, ''); + + if (placeholder.startsWith('native_')) { + placeholder = placeholder.substring(7); + } + + // like web.0_port or web_protocol + if (!placeholder.includes('_')) { + // if only one instance + const adapterInstance = `${adapter}.${instance}`; + if (_urls.length) { + _urls.forEach( + item => + (item.url = _replaceLink( + item.url, + context.instances, + adapterInstance, + placeholder, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + )), + ); + } else { + link = _replaceLink( + link, + context.instances, + adapterInstance, + placeholder, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port; + } + } else { + const [adapterInstance, attr] = placeholder.split('_'); + + // if instance number not found + if (!adapterInstance.match(/\.[0-9]+$/)) { + // list all possible instances + let ids: string[]; + if (adapter === adapterInstance) { + // take only this one instance and that's all + ids = [`${adapter}.${instance}`]; + } else { + ids = Object.keys(context.instances) + .filter( + id => + id.startsWith(`system.adapter.${adapterInstance}.`) && + context.instances[id].common.enabled, + ) + .map(id => id.substring(15)); + + // try to get disabled instances + if (!ids.length) { + ids = Object.keys(context.instances) + .filter(id => id.startsWith(`system.adapter.${adapterInstance}.`)) + .map(id => id.substring(15)); + } + } + + for (const id of ids) { + if (_urls.length) { + const item = _urls.find(t => t.instance === id); + if (item) { + item.url = _replaceLink( + item.url, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + } else { + // add new + const _link = _replaceLink( + link, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + const _port: number = context.instances[`system.adapter.${id}`]?.native + ?.port as number; + + _urls.push({ url: _link, port: _port, instance: id }); + } + } else { + const _link = _replaceLink( + link, + context.instances, + id, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + + const _port: number = context.instances[`system.adapter.${id}`]?.native + ?.port as number; + _urls.push({ url: _link, port: _port, instance: id }); + } + } + } else { + link = _replaceLink( + link, + context.instances, + adapterInstance, + attr, + placeholder, + context.hosts, + context.hostname, + context.adminInstance, + ); + + port = context.instances[`system.adapter.${adapterInstance}`]?.native?.port as number; + } + } + } + } + } + } + + if (_urls.length) { + return _urls; + } + return [{ url: link, port }]; +}