import { API_ORIGIN, APP_TYPES, PROXY_APP_ID, HSTATES, ISTATES, RSTATES } from '../constants.js'; import { eachLimit } from 'async'; import { fetcher } from '@cloudron/pankow'; import { sleep } from '@cloudron/pankow/utils'; import moment from 'moment-timezone'; import DashboardModel from './DashboardModel.js'; import ProfileModel from './ProfileModel.js'; const dashboardModel = DashboardModel.create(); const profileModel = ProfileModel.create(); function installationStateLabel(app) { if (!app) return ''; const waiting = app.progress === 0 ? ' (Queued)' : ''; switch (app.installationState) { case ISTATES.PENDING_INSTALL: return 'Installing' + waiting; case ISTATES.PENDING_CLONE: return 'Cloning' + waiting; case ISTATES.PENDING_LOCATION_CHANGE: case ISTATES.PENDING_CONFIGURE: case ISTATES.PENDING_RECREATE_CONTAINER: case ISTATES.PENDING_SERVICES_CHANGE: case ISTATES.PENDING_DEBUG: return 'Configuring' + waiting; case ISTATES.PENDING_RESIZE: return 'Resizing' + waiting; case ISTATES.PENDING_DATA_DIR_MIGRATION: return 'Migrating data' + waiting; case ISTATES.PENDING_UNINSTALL: return 'Uninstalling' + waiting; case ISTATES.PENDING_RESTORE: return 'Restoring' + waiting; case ISTATES.PENDING_IMPORT: return 'Importing' + waiting; case ISTATES.PENDING_UPDATE: return 'Updating' + waiting; case ISTATES.PENDING_BACKUP: return 'Backing up' + waiting; case ISTATES.PENDING_START: return 'Starting' + waiting; case ISTATES.PENDING_STOP: return 'Stopping' + waiting; case ISTATES.PENDING_RESTART: return 'Restarting' + waiting; case ISTATES.ERROR: { if (app.error && app.error.message === 'ETRYAGAIN') return 'DNS Error'; return 'Error'; } case ISTATES.INSTALLED: { if (app.debugMode) { return 'Recovery Mode'; } else if (app.runState === RSTATES.RUNNING) { if (!app.health) return 'Starting...'; // no data yet if (app.type === APP_TYPES.LINK) return ''; if (app.health === HSTATES.HEALTHY) return 'Running'; return 'Not responding'; // dead/exit/unhealthy } else if (app.runState === RSTATES.STOPPED) { return 'Stopped'; } else { return app.runState; } } default: return app.installationState; } } function installationActive(app) { if (app.installationState === ISTATES.ERROR) return false; if (app.installationState === ISTATES.INSTALLED) return false; return true; } function appProgressMessage(app) { return app.message || (app.error ? app.error.message : ''); } function pendingChecklistItems(app) { if (!app.checklist) return 0; return Object.keys(app.checklist).filter(function (key) { return !app.checklist[key].acknowledged; }).length; } function create() { const accessToken = localStorage.token; let config = null; let profile = null; async function loadConfigAndProfile() { let [error, result] = await dashboardModel.config(); if (error) return console.error(error); config = result; [error, result] = await profileModel.get(); if (error) return console.error(error); profile = result; } async function getTask(appId) { let error, result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/task`, { access_token: accessToken }); } catch (e) { error = e; } if (error || result.status !== 200) { console.error('Failed to get task for app.', error || result.status); return null; } return result.body; } async function postProcess(app) { if (!profile || !config) await loadConfigAndProfile(); app.ssoAuth = app.sso && (app.manifest.addons['ldap'] || app.manifest.addons['oidc'] || app.manifest.addons['proxyAuth']); // checking app.sso first ensures app.manifest.addons is not null app.type = app.manifest.id === PROXY_APP_ID ? APP_TYPES.PROXIED : APP_TYPES.APP; app.iconUrl = app.iconUrl ? `${API_ORIGIN}${app.iconUrl}?ts=${new Date(app.ts).getTime()}` : `${API_ORIGIN}/img/appicon_fallback.png`; // calculate full icon url with cache busting // only fetch if we have permissions and a taskId is set/active if (!app.taskId || (app.accessLevel !== 'operator' && app.accessLevel !== 'admin')) { app.progress = 0; app.message = ''; app.taskMinutesActive = 0; } else { const task = await getTask(app.id); if (task) { app.progress = task.percent || 5; // avoid starting with empty app.message = task.message; app.taskMinutesActive = moment.duration(moment.utc().diff(moment.utc(task.creationTime))).asMinutes(); } else { app.progress = 0; app.message = ''; app.taskMinutesActive = 0; } } function expandTemplateVars(text) { // we chose - because underscore has special meaning in markdown text = text.replace(/\$CLOUDRON-APP-LOCATION/g, app.subdomain); text = text.replace(/\$CLOUDRON-APP-DOMAIN/g, app.domain); text = text.replace(/\$CLOUDRON-APP-FQDN/g, app.fqdn); text = text.replace(/\$CLOUDRON-APP-ORIGIN/g, 'https://' + app.fqdn); text = text.replace(/\$CLOUDRON-API-DOMAIN/g, config.adminFqdn); text = text.replace(/\$CLOUDRON-API-ORIGIN/g, 'https://' + config.adminFqdn); text = text.replace(/\$CLOUDRON-USERNAME/g, profile.username); text = text.replace(/\$CLOUDRON-APP-ID/g, app.id); return text; } if (app.manifest.postInstallMessage) { let text = expandTemplateVars(app.manifest.postInstallMessage); // [^] matches even newlines. '?' makes it non-greedy if (app.sso) text = text.replace(/[^]*?<\/nosso>/g, ''); else text = text.replace(/[^]*?<\/sso>/g, ''); app.manifest.postInstallMessage = text.trim(); } // only admins have this property if (app.checklist) { for (const key of Object.keys(app.checklist)) { app.checklist[key].message = expandTemplateVars(app.checklist[key].message); } } else { app.checklist = {}; } return app; } return { name: 'AppsModel', getTask, isStopped(app) { if (app.installationState === ISTATES.PENDING_START || app.installationState === ISTATES.PENDING_STOP) { return app.installationState === ISTATES.PENDING_START; } else { return app.runState === RSTATES.STOPPED; } }, async install(manifest, config) { const data = { appStoreId: manifest.id + '@' + manifest.version, subdomain: config.subdomain, domain: config.domain, secondaryDomains: config.secondaryDomains, ports: config.ports, accessRestriction: config.accessRestriction, cert: config.cert, key: config.key, sso: config.sso, overwriteDns: config.overwriteDns, upstreamUri: config.upstreamUri, backupId: config.backupId // when restoring from archive }; let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps`, data, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null, result.body]; }, async list() { let error, result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps`, { access_token: accessToken }); } catch (e) { error = e; } if (error || result.status !== 200) return [error || result]; await eachLimit(result.body.apps, 10, postProcess); return [null, result.body.apps]; }, async get(id) { let error, result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}`, { access_token: accessToken }); } catch (e) { error = e; } if (error || result.status !== 200) return [error || result]; return [null, await postProcess(result.body)]; }, async restart(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/restart`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; while(true) { let result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}`, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; // are we done here? if (result.body.installationState !== ISTATES.INSTALLED) await sleep(2000); else return [null]; } }, async start(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/start`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null, result.body.taskId]; }, async stop(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/stop`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null, result.body.taskId]; }, async configure(id, setting, data) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/configure/${setting}`, data, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200 && result.status !== 202) return [result]; return [null]; }, async uninstall(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/uninstall`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null]; }, async archive(id, backupId) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/archive`, { backupId }, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null]; }, async getEvents(id) { let result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { page: 1, per_page: 100, access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; return [null, result.body.eventlogs]; }, async checkUpdate(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/check_update`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; return [null, result.body.update]; }, async update(id, manifest, skipBackup = false) { const data = { appStoreId: `${manifest.id}@${manifest.version}`, skipBackup: !!skipBackup, }; let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/update`, data, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null]; }, async backups(id) { // we fetch probably enough to avoid pagination let result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/backups`, { page: 1, per_page: 100, access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; return [null, result.body.backups]; }, async backup(id) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/backup`, {}, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null, result.body.taskId]; }, async updateBackup(id, backupId, label, preserveSecs) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/backups/${backupId}`, { label, preserveSecs }, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; return [null]; }, async restore(id, backupId) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/restore`, { backupId }, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null]; }, async getMetrics(id, options) { let result; try { result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/metrics`, { fromSecs: options.fromSecs, intervalSecs: options.intervalSecs, access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200) return [result]; return [null, result.body]; }, async getMetricStream(id) { return new EventSource(`${API_ORIGIN}/api/v1/apps/${id}/metricstream?access_token=${accessToken}`); }, async repair(id, data) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/repair`, data, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 200 && result.status !== 202) return [result]; return [null, result.body]; }, async ackChecklistItem(id, key, done) { let result; try { result = await fetcher.put(`${API_ORIGIN}/api/v1/apps/${id}/checklist/${key}`, { done }, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null]; }, async clone(id, config) { const data = { subdomain: config.subdomain, domain: config.domain, secondaryDomains: config.secondaryDomains, ports: config.ports, backupId: config.backupId, overwriteDns: !!config.overwriteDns }; let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/clone`, data, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 201) return [result]; return [null, result.body]; }, async import(id, remotePath, backupFormat, backupConfig) { let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/import`, { remotePath, backupFormat, backupConfig }, { access_token: accessToken }); } catch (e) { return [e]; } if (result.status !== 202) return [result]; return [null, result.body]; }, }; } export default { create, installationStateLabel, installationActive, appProgressMessage, pendingChecklistItems, };