mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
107
ui/src/app/services/api/API.def
Normal file
107
ui/src/app/services/api/API.def
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
//////////////// Install/Uninstall ////////////////////////////////////////////////
|
||||
|
||||
type AppDependentBreakage = {
|
||||
// id of the dependent app which will or did break (Stopped) given the action.
|
||||
id: string
|
||||
title: string
|
||||
iconUrl: string
|
||||
}
|
||||
|
||||
POST /apps/:appId/install(?dryrun)
|
||||
|
||||
body: {
|
||||
version: string, //semver
|
||||
}
|
||||
response : ApiAppInstalledFull & { breakages: AppDependentBreakage[] }
|
||||
|
||||
|
||||
|
||||
POST /apps/:appId/uninstall(?dryrun)
|
||||
|
||||
response : { breakages: AppDependentBreakage[] }
|
||||
|
||||
/////////////////////////////// Store/Show /////////////////////////////////////////////////
|
||||
|
||||
|
||||
type ApiAppAvailableFull = ... {
|
||||
// app base data
|
||||
id: string
|
||||
title: string
|
||||
status: AppStatus | null
|
||||
versionInstalled: string | null
|
||||
iconURL: string
|
||||
|
||||
// preview data
|
||||
versionLatest: string
|
||||
descriptionShort: string
|
||||
|
||||
// version specific data
|
||||
releaseNotes: string
|
||||
serviceRequirements: AppDependencyRequirement[]
|
||||
|
||||
// other data
|
||||
descriptionLong: string,
|
||||
version: string[],
|
||||
}
|
||||
|
||||
type AppDependencyRequirement = ... {
|
||||
//app base data (minus status + version installed)
|
||||
id: string
|
||||
title: string
|
||||
iconURL: string
|
||||
|
||||
// dependency data
|
||||
optional: string | null
|
||||
default: boolean
|
||||
versionSpec: string
|
||||
description: string | null
|
||||
violation: AppDependencyRequirementViolation | null
|
||||
}
|
||||
|
||||
type AppDependencyRequirementViolation =
|
||||
{ name: 'missing'; suggestedVersion: string; } |
|
||||
{ name: 'incompatible-version'; suggestedVersion: string; } |
|
||||
{ name: 'incompatible-config'; ruleViolations: string; auto-configurable: boolean } | // (auto-configurable for if/when we do that)
|
||||
{ name: 'incompatible-status'; status: AppStatus; }
|
||||
|
||||
|
||||
// Get App Available Full
|
||||
GET /apps/:appId/store
|
||||
|
||||
response: ApiAppAvailableFull
|
||||
|
||||
|
||||
// Get Version Specific Data for an App Available
|
||||
GET /apps/:appId/store/:version
|
||||
|
||||
response: {
|
||||
// version specific data
|
||||
releaseNotes: string
|
||||
serviceRequirements: AppDependencyRequirement[]
|
||||
}
|
||||
|
||||
///////////////////////////// Installed/Show ///////////////////////////////////////////
|
||||
|
||||
|
||||
type ApiAppInstalledFull {
|
||||
// app base data
|
||||
id: string
|
||||
title: string
|
||||
status: AppStatus | null
|
||||
versionInstalled: string | null
|
||||
iconURL: string
|
||||
|
||||
// preview data
|
||||
|
||||
// other data
|
||||
instructions: string | null
|
||||
lastBackup: string | null
|
||||
configuredRequirements: AppDependencyRequirement[] | null // null if not yet configured
|
||||
}
|
||||
|
||||
|
||||
// Get App Installed Full
|
||||
GET /apps/:appId/installed
|
||||
|
||||
reseponse: AppInstalledFull
|
||||
32
ui/src/app/services/api/api-types.ts
Normal file
32
ui/src/app/services/api/api-types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ConfigSpec } from 'src/app/app-config/config-types'
|
||||
import { AppAvailableFull, AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { Rules } from '../../models/app-model'
|
||||
import { SSHFingerprint, ServerStatus, ServerSpecs } from '../../models/server-model'
|
||||
|
||||
/** SERVER **/
|
||||
|
||||
export interface ApiServer {
|
||||
name: string
|
||||
status: ServerStatus
|
||||
versionInstalled: string
|
||||
alternativeRegistryUrl: string | null
|
||||
specs: ServerSpecs
|
||||
wifi: { ssids: string[]; current: string; }
|
||||
ssh: SSHFingerprint[]
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/** APPS **/
|
||||
export type ApiAppAvailableFull = Omit<AppAvailableFull, 'versionViewing'>
|
||||
export type ApiAppInstalledFull = Omit<AppInstalledFull, 'hasFetchedFull'>
|
||||
|
||||
export interface ApiAppConfig {
|
||||
spec: ConfigSpec
|
||||
config: object | null
|
||||
rules: Rules[]
|
||||
}
|
||||
|
||||
/** MISC **/
|
||||
|
||||
export type Unit = { never?: never; } // hack for the unit typ
|
||||
|
||||
14
ui/src/app/services/api/api.service.factory.ts
Normal file
14
ui/src/app/services/api/api.service.factory.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { HttpService } from '../http.service'
|
||||
import { AppModel } from '../../models/app-model'
|
||||
import { MockApiService } from './mock-api.service'
|
||||
import { LiveApiService } from './live-api.service'
|
||||
import { ServerModel } from 'src/app/models/server-model'
|
||||
import { ConfigService } from '../config.service'
|
||||
|
||||
export function ApiServiceFactory (config: ConfigService, http: HttpService, appModel: AppModel, serverModel: ServerModel) {
|
||||
if (config.api.useMocks) {
|
||||
return new MockApiService(appModel, serverModel)
|
||||
} else {
|
||||
return new LiveApiService(http, appModel, serverModel)
|
||||
}
|
||||
}
|
||||
97
ui/src/app/services/api/api.service.ts
Normal file
97
ui/src/app/services/api/api.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Rules } from '../../models/app-model'
|
||||
import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
|
||||
import { S9Notification, SSHFingerprint, ServerMetrics, DiskInfo } from '../../models/server-model'
|
||||
import { Subject, Observable } from 'rxjs'
|
||||
import { Unit, ApiServer, ApiAppInstalledFull, ApiAppConfig, ApiAppAvailableFull } from './api-types'
|
||||
import { AppMetrics, AppMetricsVersioned } from 'src/app/util/metrics.util'
|
||||
import { ConfigSpec } from 'src/app/app-config/config-types'
|
||||
|
||||
export abstract class ApiService {
|
||||
private $unauthorizedApiResponse$: Subject<{ }> = new Subject()
|
||||
|
||||
watch401$ (): Observable<{ }> {
|
||||
return this.$unauthorizedApiResponse$.asObservable()
|
||||
}
|
||||
|
||||
authenticatedRequestsEnabled: boolean = false
|
||||
|
||||
protected received401 () {
|
||||
this.authenticatedRequestsEnabled = false
|
||||
this.$unauthorizedApiResponse$.next()
|
||||
}
|
||||
|
||||
abstract getCheckAuth (): Promise<Unit> // Throws an error on failed auth.
|
||||
abstract postLogin (password: string): Promise<Unit> // Throws an error on failed auth.
|
||||
abstract postLogout (): Promise<Unit> // Throws an error on failed auth.
|
||||
abstract getServer (timeout?: number): Promise<ApiServer>
|
||||
abstract getVersionLatest (): Promise<ReqRes.GetVersionLatestRes>
|
||||
abstract getServerMetrics (): Promise<ReqRes.GetServerMetricsRes>
|
||||
abstract getNotifications (page: number, perPage: number): Promise<S9Notification[]>
|
||||
abstract deleteNotification (id: string): Promise<Unit>
|
||||
abstract updateAgent (thing: any): Promise<Unit>
|
||||
abstract getAvailableApps (): Promise<AppAvailablePreview[]>
|
||||
abstract getAvailableApp (appId: string): Promise<AppAvailableFull>
|
||||
abstract getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo>
|
||||
abstract getInstalledApp (appId: string): Promise<AppInstalledFull>
|
||||
abstract getAppMetrics (appId: string): Promise<AppMetrics>
|
||||
abstract getInstalledApps (): Promise<AppInstalledPreview[]>
|
||||
abstract getExternalDisks (): Promise<DiskInfo[]>
|
||||
abstract getAppConfig (appId: string): Promise<{ spec: ConfigSpec, config: object, rules: Rules[]}>
|
||||
abstract getAppLogs (appId: string, params?: ReqRes.GetAppLogsReq): Promise<string[]>
|
||||
abstract installApp (appId: string, version: string, dryRun?: boolean): Promise<AppInstalledFull & { breakages: DependentBreakage[] } >
|
||||
abstract uninstallApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
|
||||
abstract startApp (appId: string): Promise<Unit>
|
||||
abstract stopApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
|
||||
abstract restartApp (appId: string): Promise<Unit>
|
||||
abstract createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
|
||||
abstract restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
|
||||
abstract stopAppBackup (appId: string): Promise<Unit>
|
||||
abstract patchAppConfig (app: AppInstalledPreview, config: object, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
|
||||
abstract postConfigureDependency(dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{config: object, breakages: DependentBreakage[] }>
|
||||
abstract patchServerConfig (attr: string, value: any): Promise<Unit>
|
||||
abstract wipeAppData (app: AppInstalledPreview): Promise<Unit>
|
||||
abstract addSSHKey (sshKey: string): Promise<Unit>
|
||||
abstract deleteSSHKey (sshKey: SSHFingerprint): Promise<Unit>
|
||||
abstract addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit>
|
||||
abstract connectWifi (ssid: string): Promise<Unit>
|
||||
abstract deleteWifi (ssid: string): Promise<Unit>
|
||||
abstract restartServer (): Promise<Unit>
|
||||
abstract shutdownServer (): Promise<Unit>
|
||||
}
|
||||
|
||||
export module ReqRes {
|
||||
export type GetVersionRes = { version: string }
|
||||
export type PostLoginReq = { password: string }
|
||||
export type PostLoginRes = Unit
|
||||
export type GetCheckAuthRes = { }
|
||||
export type GetServerRes = ApiServer
|
||||
export type GetVersionLatestRes = { versionLatest: string, canUpdate: boolean }
|
||||
export type GetServerMetricsRes = ServerMetrics
|
||||
export type GetAppAvailableRes = ApiAppAvailableFull
|
||||
export type GetAppAvailableVersionInfoRes = AppAvailableVersionSpecificInfo
|
||||
export type GetAppsAvailableRes = AppAvailablePreview[]
|
||||
export type GetExternalDisksRes = DiskInfo[]
|
||||
export type GetAppInstalledRes = ApiAppInstalledFull
|
||||
export type GetAppConfigRes = ApiAppConfig
|
||||
export type GetAppLogsReq = { after?: string, before?: string, page?: string, perPage?: string }
|
||||
export type GetAppLogsRes = string[]
|
||||
export type GetAppMetricsRes = AppMetricsVersioned<number>
|
||||
export type GetAppsInstalledRes = AppInstalledPreview[]
|
||||
export type PostInstallAppReq = { version: string }
|
||||
export type PostInstallAppRes = ApiAppInstalledFull & { breakages: DependentBreakage[] }
|
||||
export type PostUpdateAgentReq = { version: string }
|
||||
export type PostAppBackupCreateReq = { logicalname: string, password: string }
|
||||
export type PostAppBackupCreateRes = Unit
|
||||
export type PostAppBackupRestoreReq = { logicalname: string, password: string }
|
||||
export type PostAppBackupRestoreRes = Unit
|
||||
export type PostAppBackupStopRes = Unit
|
||||
export type PatchAppConfigReq = { config: object }
|
||||
export type PatchServerConfigReq = { value: string }
|
||||
export type GetNotificationsReq = { page: string, perPage: string }
|
||||
export type GetNotificationsRes = S9Notification[]
|
||||
export type PostAddWifiReq = { ssid: string, password: string, country: string, skipConnect: boolean }
|
||||
export type PostConnectWifiReq = { country: string }
|
||||
export type PostAddSSHKeyReq = { sshKey: string }
|
||||
export type PostAddSSHKeyRes = SSHFingerprint
|
||||
}
|
||||
|
||||
257
ui/src/app/services/api/live-api.service.ts
Normal file
257
ui/src/app/services/api/live-api.service.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpService, Method, HttpOptions } from '../http.service'
|
||||
import { AppModel, AppStatus } from '../../models/app-model'
|
||||
import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPreview, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
|
||||
import { S9Notification, SSHFingerprint, ServerModel, DiskInfo } from '../../models/server-model'
|
||||
import { ApiService, ReqRes } from './api.service'
|
||||
import { ApiServer, Unit } from './api-types'
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import { isUnauthorized } from 'src/app/util/web.util'
|
||||
import { Replace } from 'src/app/util/types.util'
|
||||
import { AppMetrics, parseMetricsPermissive } from 'src/app/util/metrics.util'
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService extends ApiService {
|
||||
constructor (
|
||||
private readonly http: HttpService,
|
||||
// TODO remove app + server model from here. updates to state should be done in a separate class wrapping ApiService + App/ServerModel
|
||||
private readonly appModel: AppModel,
|
||||
private readonly serverModel: ServerModel,
|
||||
) { super() }
|
||||
|
||||
// Used to check whether password or key is valid. If so, it will be used implicitly by all other calls.
|
||||
async getCheckAuth (): Promise<Unit> {
|
||||
return this.http.serverRequest<Unit>({ method: Method.GET, url: '/authenticate' }, { version: '' })
|
||||
}
|
||||
|
||||
async postLogin (password: string): Promise<Unit> {
|
||||
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/login', data: { password } }, { version: '' })
|
||||
}
|
||||
|
||||
async postLogout (): Promise<Unit> {
|
||||
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/logout' }, { version: '' }).then(() => { this.authenticatedRequestsEnabled = false; return { } })
|
||||
}
|
||||
|
||||
async getServer (timeout?: number): Promise<ApiServer> {
|
||||
return this.authRequest<ReqRes.GetServerRes>({ method: Method.GET, url: '/', readTimeout: timeout })
|
||||
}
|
||||
|
||||
async getVersionLatest (): Promise<ReqRes.GetVersionLatestRes> {
|
||||
return this.authRequest<ReqRes.GetVersionLatestRes>({ method: Method.GET, url: '/versionLatest' }, { version: '' })
|
||||
}
|
||||
|
||||
async getServerMetrics (): Promise<ReqRes.GetServerMetricsRes> {
|
||||
return this.authRequest<ReqRes.GetServerMetricsRes>({ method: Method.GET, url: `/metrics` })
|
||||
}
|
||||
|
||||
async getNotifications (page: number, perPage: number): Promise<S9Notification[]> {
|
||||
const params: ReqRes.GetNotificationsReq = {
|
||||
page: String(page),
|
||||
perPage: String(perPage),
|
||||
}
|
||||
return this.authRequest<ReqRes.GetNotificationsRes>({ method: Method.GET, url: `/notifications`, params })
|
||||
}
|
||||
|
||||
async deleteNotification (id: string): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.DELETE, url: `/notifications/${id}` })
|
||||
}
|
||||
|
||||
async getExternalDisks (): Promise<DiskInfo[]> {
|
||||
return this.authRequest<ReqRes.GetExternalDisksRes>({ method: Method.GET, url: `/disks` })
|
||||
}
|
||||
|
||||
async updateAgent (version: string): Promise<Unit> {
|
||||
const data: ReqRes.PostUpdateAgentReq = {
|
||||
version: `=${version}`,
|
||||
}
|
||||
return this.authRequest({ method: Method.POST, url: '/update', data })
|
||||
}
|
||||
|
||||
async getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo> {
|
||||
return this
|
||||
.authRequest<Replace<ReqRes.GetAppAvailableVersionInfoRes, 'versionViewing', 'version'>>( { method: Method.GET, url: `/apps/${appId}/store/${versionSpec}` })
|
||||
.then( res => ({ ...res, versionViewing: res.version }))
|
||||
.then( res => {
|
||||
delete res['version']
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
async getAvailableApps (): Promise<AppAvailablePreview[]> {
|
||||
return this.authRequest<ReqRes.GetAppsAvailableRes>({ method: Method.GET, url: '/apps/store' })
|
||||
}
|
||||
|
||||
async getAvailableApp (appId: string): Promise<AppAvailableFull> {
|
||||
return this.authRequest<ReqRes.GetAppAvailableRes>({ method: Method.GET, url: `/apps/${appId}/store` })
|
||||
.then(res => {
|
||||
return {
|
||||
...res,
|
||||
versionViewing: res.versionLatest,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getInstalledApp (appId: string): Promise<AppInstalledFull> {
|
||||
return this.authRequest<ReqRes.GetAppInstalledRes>({ method: Method.GET, url: `/apps/${appId}/installed` })
|
||||
.then(app => ({ ...app, hasFetchedFull: true }))
|
||||
}
|
||||
|
||||
async getInstalledApps (): Promise<AppInstalledPreview[]> {
|
||||
return this.authRequest<ReqRes.GetAppsInstalledRes>({ method: Method.GET, url: `/apps/installed` })
|
||||
}
|
||||
|
||||
async getAppConfig ( appId: string): Promise<ReqRes.GetAppConfigRes> {
|
||||
return this.authRequest<ReqRes.GetAppConfigRes>({ method: Method.GET, url: `/apps/${appId}/config` })
|
||||
}
|
||||
|
||||
async getAppLogs (appId: string, params: ReqRes.GetAppLogsReq = { }): Promise<string[]> {
|
||||
return this.authRequest<ReqRes.GetAppLogsRes>( { method: Method.GET, url: `/apps/${appId}/logs`, params: params as any })
|
||||
}
|
||||
|
||||
async getAppMetrics (appId: string): Promise<AppMetrics> {
|
||||
return this.authRequest<ReqRes.GetAppMetricsRes | string>( { method: Method.GET, url: `/apps/${appId}/metrics` })
|
||||
.then(parseMetricsPermissive)
|
||||
}
|
||||
|
||||
async installApp (appId: string, version: string, dryRun: boolean = false): Promise<AppInstalledFull & { breakages: DependentBreakage[] }> {
|
||||
const data: ReqRes.PostInstallAppReq = {
|
||||
version,
|
||||
}
|
||||
return this.authRequest<ReqRes.PostInstallAppRes>({ method: Method.POST, url: `/apps/${appId}/install${dryRunParam(dryRun, true)}`, data })
|
||||
.then(res => ({ ...res, hasFetchedFull: false }))
|
||||
}
|
||||
|
||||
async uninstallApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
|
||||
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/uninstall${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
|
||||
}
|
||||
|
||||
async startApp (appId: string): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/start`, readTimeout: 30000 })
|
||||
.then(() => this.appModel.update({ id: appId, status: AppStatus.RUNNING }))
|
||||
.then(() => ({ }))
|
||||
}
|
||||
|
||||
async stopApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
|
||||
const res = await this.authRequest<{ breakages: DependentBreakage[] }>({ method: Method.POST, url: `/apps/${appId}/stop${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
|
||||
if (!dryRun) this.appModel.update({ id: appId, status: AppStatus.STOPPING })
|
||||
return res
|
||||
}
|
||||
|
||||
async restartApp (appId: string): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/restart`, readTimeout: 30000 })
|
||||
.then(() => ({ } as any))
|
||||
}
|
||||
|
||||
async createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
|
||||
const data: ReqRes.PostAppBackupCreateReq = {
|
||||
password: password || undefined,
|
||||
logicalname,
|
||||
}
|
||||
return this.authRequest<ReqRes.PostAppBackupCreateRes>({ method: Method.POST, url: `/apps/${appId}/backup`, data, readTimeout: 30000 })
|
||||
.then(() => this.appModel.update({ id: appId, status: AppStatus.CREATING_BACKUP }))
|
||||
.then(() => ({ }))
|
||||
}
|
||||
|
||||
async stopAppBackup (appId: string): Promise<Unit> {
|
||||
return this.authRequest<ReqRes.PostAppBackupStopRes>({ method: Method.POST, url: `/apps/${appId}/backup/stop`, readTimeout: 30000 })
|
||||
.then(() => this.appModel.update({ id: appId, status: AppStatus.STOPPED }))
|
||||
.then(() => ({ }))
|
||||
}
|
||||
|
||||
async restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
|
||||
const data: ReqRes.PostAppBackupRestoreReq = {
|
||||
password: password || undefined,
|
||||
logicalname,
|
||||
}
|
||||
return this.authRequest<ReqRes.PostAppBackupRestoreRes>({ method: Method.POST, url: `/apps/${appId}/backup/restore`, data, readTimeout: 30000 })
|
||||
.then(() => this.appModel.update({ id: appId, status: AppStatus.RESTORING_BACKUP }))
|
||||
.then(() => ({ }))
|
||||
}
|
||||
|
||||
async patchAppConfig (app: AppInstalledPreview, config: object, dryRun = false): Promise<{ breakages: DependentBreakage[] }> {
|
||||
const data: ReqRes.PatchAppConfigReq = {
|
||||
config,
|
||||
}
|
||||
return this.authRequest({ method: Method.PATCH, url: `/apps/${app.id}/config${dryRunParam(dryRun, true)}`, data, readTimeout: 30000 })
|
||||
}
|
||||
|
||||
async postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{ config: object, breakages: DependentBreakage[] }> {
|
||||
return this.authRequest({ method: Method.POST, url: `/apps/${dependencyId}/autoconfig/${dependentId}${dryRunParam(dryRun, true)}`, readTimeout: 30000 })
|
||||
}
|
||||
|
||||
async patchServerConfig (attr: string, value: any): Promise<Unit> {
|
||||
const data: ReqRes.PatchServerConfigReq = {
|
||||
value,
|
||||
}
|
||||
return this.authRequest({ method: Method.PATCH, url: `/${attr}`, data, readTimeout: 30000 })
|
||||
.then(() => this.serverModel.update({ [attr]: value }))
|
||||
.then(() => ({ }))
|
||||
}
|
||||
|
||||
async wipeAppData (app: AppInstalledPreview): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: `/apps/${app.id}/wipe`, readTimeout: 30000 }).then((res) => {
|
||||
this.appModel.update({ id: app.id, status: AppStatus.NEEDS_CONFIG })
|
||||
return res
|
||||
})
|
||||
}
|
||||
|
||||
async addSSHKey (sshKey: string): Promise<Unit> {
|
||||
const data: ReqRes.PostAddSSHKeyReq = {
|
||||
sshKey,
|
||||
}
|
||||
const fingerprint = await this.authRequest<ReqRes.PostAddSSHKeyRes>({ method: Method.POST, url: `/sshKeys`, data })
|
||||
this.serverModel.update({ ssh: [...this.serverModel.peek().ssh, fingerprint] })
|
||||
return { }
|
||||
}
|
||||
|
||||
async addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit> {
|
||||
const data: ReqRes.PostAddWifiReq = {
|
||||
ssid,
|
||||
password,
|
||||
country,
|
||||
skipConnect: !connect,
|
||||
}
|
||||
return this.authRequest({ method: Method.POST, url: `/wifi`, data })
|
||||
}
|
||||
|
||||
async connectWifi (ssid: string): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: encodeURI(`/wifi/${ssid}`) })
|
||||
}
|
||||
|
||||
async deleteWifi (ssid: string): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.DELETE, url: encodeURI(`/wifi/${ssid}`) })
|
||||
}
|
||||
|
||||
async deleteSSHKey (fingerprint: SSHFingerprint): Promise<Unit> {
|
||||
await this.authRequest({ method: Method.DELETE, url: `/sshKeys/${fingerprint.hash}` })
|
||||
const ssh = this.serverModel.peek().ssh
|
||||
this.serverModel.update({ ssh: ssh.filter(s => s !== fingerprint) })
|
||||
return { }
|
||||
}
|
||||
|
||||
async restartServer (): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: '/restart', readTimeout: 30000 })
|
||||
}
|
||||
|
||||
async shutdownServer (): Promise<Unit> {
|
||||
return this.authRequest({ method: Method.POST, url: '/shutdown', readTimeout: 30000 })
|
||||
}
|
||||
|
||||
private async authRequest<T> (opts: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
|
||||
if (!this.authenticatedRequestsEnabled) throw new Error(`Authenticated requests are not enabled. Do you need to login?`)
|
||||
|
||||
opts.withCredentials = true
|
||||
return this.http.serverRequest<T>(opts, overrides).catch((e: HttpError) => {
|
||||
console.log(`Got a server error!`, e)
|
||||
if (isUnauthorized(e)) this.received401()
|
||||
throw e
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
|
||||
|
||||
const dryRunParam = (dryRun: boolean, first: boolean) => {
|
||||
if (!dryRun) return ''
|
||||
return first ? `?dryrun` : `&dryrun`
|
||||
}
|
||||
477
ui/src/app/services/api/md-sample.md
Normal file
477
ui/src/app/services/api/md-sample.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Size Limit [![Cult Of Martians][cult-img]][cult]
|
||||
|
||||
<img src="https://ai.github.io/size-limit/logo.svg" align="right"
|
||||
alt="Size Limit logo by Anton Lovchikov" width="120" height="178">
|
||||
|
||||
Size Limit is a performance budget tool for JavaScript. It checks every commit
|
||||
on CI, calculates the real cost of your JS for end-users and throws an error
|
||||
if the cost exceeds the limit.
|
||||
|
||||
* **ES modules** and **tree-shaking** support.
|
||||
* Add Size Limit to **Travis CI**, **Circle CI**, **GitHub Actions**
|
||||
or another CI system to know if a pull request adds a massive dependency.
|
||||
* **Modular** to fit different use cases: big JS applications
|
||||
that use their own bundler or small npm libraries with many files.
|
||||
* Can calculate **the time** it would take a browser
|
||||
to download and **execute** your JS. Time is a much more accurate
|
||||
and understandable metric compared to the size in bytes.
|
||||
* Calculations include **all dependencies and polyfills**
|
||||
used in your JS.
|
||||
|
||||
<p align="center">
|
||||
<img src="./img/example.png" alt="Size Limit CLI" width="738">
|
||||
</p>
|
||||
|
||||
With **[GitHub action]** Size Limit will post bundle size changes as a comment
|
||||
in pull request discussion.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/andresz1/size-limit-action/master/assets/pr.png"
|
||||
alt="Size Limit comment in pull request about bundle size changes"
|
||||
width="686" height="289">
|
||||
</p>
|
||||
|
||||
With `--why`, Size Limit can tell you *why* your library is of this size
|
||||
and show the real cost of all your internal dependencies.
|
||||
|
||||
<p align="center">
|
||||
<img src="./img/why.png" alt="Bundle Analyzer example" width="650">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://evilmartians.com/?utm_source=size-limit">
|
||||
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg"
|
||||
alt="Sponsored by Evil Martians" width="236" height="54">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[GitHub action]: https://github.com/andresz1/size-limit-action
|
||||
[cult-img]: http://cultofmartians.com/assets/badges/badge.svg
|
||||
[cult]: http://cultofmartians.com/tasks/size-limit-config.html
|
||||
|
||||
## Who Uses Size Limit
|
||||
|
||||
* [MobX](https://github.com/mobxjs/mobx)
|
||||
* [Material-UI](https://github.com/callemall/material-ui)
|
||||
* [Autoprefixer](https://github.com/postcss/autoprefixer)
|
||||
* [PostCSS](https://github.com/postcss/postcss) reduced
|
||||
[25% of the size](https://github.com/postcss/postcss/commit/150edaa42f6d7ede73d8c72be9909f0a0f87a70f).
|
||||
* [Browserslist](https://github.com/ai/browserslist) reduced
|
||||
[25% of the size](https://github.com/ai/browserslist/commit/640b62fa83a20897cae75298a9f2715642531623).
|
||||
* [EmojiMart](https://github.com/missive/emoji-mart) reduced
|
||||
[20% of the size](https://github.com/missive/emoji-mart/pull/111)
|
||||
* [nanoid](https://github.com/ai/nanoid) reduced
|
||||
[33% of the size](https://github.com/ai/nanoid/commit/036612e7d6cc5760313a8850a2751a5e95184eab).
|
||||
* [React Focus Lock](https://github.com/theKashey/react-focus-lock) reduced
|
||||
[32% of the size](https://github.com/theKashey/react-focus-lock/pull/48).
|
||||
* [Logux](https://github.com/logux) reduced
|
||||
[90% of the size](https://github.com/logux/logux-client/commit/62b258e20e1818b23ae39b9c4cd49e2495781e91).
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Size Limit contains a CLI tool, 3 plugins (`file`, `webpack`, `time`)
|
||||
and 3 plugin presets for popular use cases (`app`, `big-lib`, `small-lib`).
|
||||
A CLI tool finds plugins in `package.json` and loads the config.
|
||||
2. If you use the `webpack` plugin, Size Limit will bundle your JS files into
|
||||
a single file. It is important to track dependencies and webpack polyfills.
|
||||
It is also useful for small libraries with many small files and without
|
||||
a bundler.
|
||||
3. The `webpack` plugin creates an empty webpack project, adds your library
|
||||
and looks for the bundle size difference.
|
||||
4. The `time` plugin compares the current machine performance with that of
|
||||
a low-priced Android devices to calculate the CPU throttling rate.
|
||||
5. Then the `time` plugin runs headless Chrome (or desktop Chrome if it’s
|
||||
available) to track the time a browser takes to compile and execute your JS.
|
||||
Note that these measurements depend on available resources and might
|
||||
be unstable. [See here](https://github.com/mbalabash/estimo/issues/5)
|
||||
for more details.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### JS Applications
|
||||
|
||||
Suitable for applications that have their own bundler and send the JS bundle
|
||||
directly to a client (without publishing it to npm). Think of a user-facing app
|
||||
or website, like an email client, a CRM, a landing page or a blog with
|
||||
interactive elements, using React/Vue/Svelte lib or vanilla JS.
|
||||
|
||||
<details><summary><b>Show instructions</b></summary>
|
||||
|
||||
1. Install the preset:
|
||||
|
||||
```sh
|
||||
$ npm install --save-dev size-limit @size-limit/preset-app
|
||||
```
|
||||
|
||||
2. Add the `size-limit` section and the `size` script to your `package.json`:
|
||||
|
||||
```diff
|
||||
+ "size-limit": [
|
||||
+ {
|
||||
+ "path": "dist/app-*.js"
|
||||
+ }
|
||||
+ ],
|
||||
"scripts": {
|
||||
"build": "webpack ./webpack.config.js",
|
||||
+ "size": "npm run build && size-limit",
|
||||
"test": "jest && eslint ."
|
||||
}
|
||||
```
|
||||
|
||||
3. Here’s how you can get the size for your current project:
|
||||
|
||||
```sh
|
||||
$ npm run size
|
||||
|
||||
Package size: 30.08 KB with all dependencies, minified and gzipped
|
||||
Loading time: 602 ms on slow 3G
|
||||
Running time: 214 ms on Snapdragon 410
|
||||
Total time: 815 ms
|
||||
```
|
||||
|
||||
4. Now, let’s set the limit. Add 25% to the current total time and use that as
|
||||
the limit in your `package.json`:
|
||||
|
||||
```diff
|
||||
"size-limit": [
|
||||
{
|
||||
+ "limit": "1 s",
|
||||
"path": "dist/app-*.js"
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
5. Add the `size` script to your test suite:
|
||||
|
||||
```diff
|
||||
"scripts": {
|
||||
"build": "webpack ./webpack.config.js",
|
||||
"size": "npm run build && size-limit",
|
||||
- "test": "jest && eslint ."
|
||||
+ "test": "jest && eslint . && npm run size"
|
||||
}
|
||||
```
|
||||
|
||||
6. If you don’t have a continuous integration service running, don’t forget
|
||||
to add one — start with [Travis CI].
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Big Libraries
|
||||
|
||||
JS libraries > 10 KB in size.
|
||||
|
||||
This preset includes headless Chrome, and will measure your lib’s execution
|
||||
time. You likely don’t need this overhead for a small 2 KB lib, but for larger
|
||||
ones the execution time is a more accurate and understandable metric that
|
||||
the size in bytes. Library like [React] is a good example for this preset.
|
||||
|
||||
<details><summary><b>Show instructions</b></summary>
|
||||
|
||||
1. Install preset:
|
||||
|
||||
```sh
|
||||
$ npm install --save-dev size-limit @size-limit/preset-big-lib
|
||||
```
|
||||
|
||||
2. Add the `size-limit` section and the `size` script to your `package.json`:
|
||||
|
||||
```diff
|
||||
+ "size-limit": [
|
||||
+ {
|
||||
+ "path": "dist/react.production-*.js"
|
||||
+ }
|
||||
+ ],
|
||||
"scripts": {
|
||||
"build": "webpack ./scripts/rollup/build.js",
|
||||
+ "size": "npm run build && size-limit",
|
||||
"test": "jest && eslint ."
|
||||
}
|
||||
```
|
||||
|
||||
3. If you use ES modules you can test the size after tree-shaking with `import`
|
||||
option:
|
||||
|
||||
```diff
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "dist/react.production-*.js",
|
||||
+ "import": "{ createComponent }"
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
4. Here’s how you can get the size for your current project:
|
||||
|
||||
```sh
|
||||
$ npm run size
|
||||
|
||||
Package size: 30.08 KB with all dependencies, minified and gzipped
|
||||
Loading time: 602 ms on slow 3G
|
||||
Running time: 214 ms on Snapdragon 410
|
||||
Total time: 815 ms
|
||||
```
|
||||
|
||||
5. Now, let’s set the limit. Add 25% to the current total time and use that
|
||||
as the limit in your `package.json`:
|
||||
|
||||
```diff
|
||||
"size-limit": [
|
||||
{
|
||||
+ "limit": "1 s",
|
||||
"path": "dist/react.production-*.js"
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
6. Add a `size` script to your test suite:
|
||||
|
||||
```diff
|
||||
"scripts": {
|
||||
"build": "rollup ./scripts/rollup/build.js",
|
||||
"size": "npm run build && size-limit",
|
||||
- "test": "jest && eslint ."
|
||||
+ "test": "jest && eslint . && npm run size"
|
||||
}
|
||||
```
|
||||
|
||||
7. If you don’t have a continuous integration service running, don’t forget
|
||||
to add one — start with [Travis CI].
|
||||
8. Add the library size to docs, it will help users to choose your project:
|
||||
|
||||
```diff
|
||||
# Project Name
|
||||
|
||||
Short project description
|
||||
|
||||
* **Fast.** 10% faster than competitor.
|
||||
+ * **Small.** 15 KB (minified and gzipped).
|
||||
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Small Libraries
|
||||
|
||||
JS libraries < 10 KB in size.
|
||||
|
||||
This preset will only measure the size, without the execution time, so it’s
|
||||
suitable for small libraries. If your library is larger, you likely want
|
||||
the Big Libraries preset above. [Nano ID] or [Storeon] are good examples
|
||||
for this preset.
|
||||
|
||||
<details><summary><b>Show instructions</b></summary>
|
||||
|
||||
1. First, install `size-limit`:
|
||||
|
||||
```sh
|
||||
$ npm install --save-dev size-limit @size-limit/preset-small-lib
|
||||
```
|
||||
|
||||
2. Add the `size-limit` section and the `size` script to your `package.json`:
|
||||
|
||||
```diff
|
||||
+ "size-limit": [
|
||||
+ {
|
||||
+ "path": "index.js"
|
||||
+ }
|
||||
+ ],
|
||||
"scripts": {
|
||||
+ "size": "size-limit",
|
||||
"test": "jest && eslint ."
|
||||
}
|
||||
```
|
||||
|
||||
3. Here’s how you can get the size for your current project:
|
||||
|
||||
```sh
|
||||
$ npm run size
|
||||
|
||||
Package size: 177 B with all dependencies, minified and gzipped
|
||||
```
|
||||
|
||||
4. If your project size starts to look bloated, run `--why` for analysis:
|
||||
|
||||
```sh
|
||||
$ npm run size -- --why
|
||||
```
|
||||
|
||||
5. Now, let’s set the limit. Determine the current size of your library,
|
||||
add just a little bit (a kilobyte, maybe) and use that as the limit
|
||||
in your `package.json`:
|
||||
|
||||
```diff
|
||||
"size-limit": [
|
||||
{
|
||||
+ "limit": "9 KB",
|
||||
"path": "index.js"
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
6. Add the `size` script to your test suite:
|
||||
|
||||
```diff
|
||||
"scripts": {
|
||||
"size": "size-limit",
|
||||
- "test": "jest && eslint ."
|
||||
+ "test": "jest && eslint . && npm run size"
|
||||
}
|
||||
```
|
||||
|
||||
7. If you don’t have a continuous integration service running, don’t forget
|
||||
to add one — start with [Travis CI].
|
||||
8. Add the library size to docs, it will help users to choose your project:
|
||||
|
||||
```diff
|
||||
# Project Name
|
||||
|
||||
Short project description
|
||||
|
||||
* **Fast.** 10% faster than competitor.
|
||||
+ * **Small.** 500 bytes (minified and gzipped). No dependencies.
|
||||
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
[Travis CI]: https://github.com/dwyl/learn-travis
|
||||
[Storeon]: https://github.com/ai/storeon/
|
||||
[Nano ID]: https://github.com/ai/nanoid/
|
||||
[React]: https://github.com/facebook/react/
|
||||
|
||||
|
||||
## Reports
|
||||
|
||||
Size Limit has a [GitHub action] that comments and rejects pull requests based
|
||||
on Size Limit output.
|
||||
|
||||
1. Install and configure Size Limit as shown above.
|
||||
2. Add the following action inside `.github/workflows/size-limit.yml`
|
||||
|
||||
```yaml
|
||||
name: "size"
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
size:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: andresz1/size-limit-action@v1.0.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
|
||||
## Config
|
||||
|
||||
Size Limits supports three ways to define config.
|
||||
|
||||
1. `size-limit` section in `package.json`:
|
||||
|
||||
```json
|
||||
"size-limit": [
|
||||
{
|
||||
"path": "index.js",
|
||||
"import": "{ createStore }",
|
||||
"limit": "500 ms"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
2. or a separate `.size-limit.json` config file:
|
||||
|
||||
```js
|
||||
[
|
||||
{
|
||||
"path": "index.js",
|
||||
"import": "{ createStore }",
|
||||
"limit": "500 ms"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
3. or a more flexible `.size-limit.js` config file:
|
||||
|
||||
```js
|
||||
module.exports = [
|
||||
{
|
||||
path: "index.js",
|
||||
import: "{ createStore }",
|
||||
limit: "500 ms"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Each section in the config can have these options:
|
||||
|
||||
* **path**: relative paths to files. The only mandatory option.
|
||||
It could be a path `"index.js"`, a [pattern] `"dist/app-*.js"`
|
||||
or an array `["index.js", "dist/app-*.js", "!dist/app-exclude.js"]`.
|
||||
* **import**: partial import to test tree-shaking. It could be `"{ lib }"`
|
||||
to test `import { lib } from 'lib'` or `{ "a.js": "{ a }", "b.js": "{ b }" }`
|
||||
to test multiple files.
|
||||
* **limit**: size or time limit for files from the `path` option. It should be
|
||||
a string with a number and unit, separated by a space.
|
||||
Format: `100 B`, `10 KB`, `500 ms`, `1 s`.
|
||||
* **name**: the name of the current section. It will only be useful
|
||||
if you have multiple sections.
|
||||
* **entry**: when using a custom webpack config, a webpack entry could be given.
|
||||
It could be a string or an array of strings.
|
||||
By default, the total size of all entry points will be checked.
|
||||
* **webpack**: with `false` it will disable webpack.
|
||||
* **running**: with `false` it will disable calculating running time.
|
||||
* **gzip**: with `false` it will disable gzip compression.
|
||||
* **brotli**: with `true` it will use brotli compression and disable gzip compression.
|
||||
* **config**: a path to a custom webpack config.
|
||||
* **ignore**: an array of files and dependencies to exclude from
|
||||
the project size calculation.
|
||||
|
||||
If you use Size Limit to track the size of CSS files, make sure to set
|
||||
`webpack: false`. Otherwise, you will get wrong numbers, because webpack
|
||||
inserts `style-loader` runtime (≈2 KB) into the bundle.
|
||||
|
||||
[pattern]: https://github.com/sindresorhus/globby#globbing-patterns
|
||||
|
||||
|
||||
## Plugins and Presets
|
||||
|
||||
Plugins:
|
||||
|
||||
* `@size-limit/file` checks the size of files with Gzip, Brotli
|
||||
or without compression.
|
||||
* `@size-limit/webpack` adds your library to empty webpack project
|
||||
and prepares bundle file for `file` plugin.
|
||||
* `@size-limit/time` uses headless Chrome to track time to execute JS.
|
||||
* `@size-limit/dual-publish` compiles files to ES modules with [`dual-publish`]
|
||||
to check size after tree-shaking.
|
||||
|
||||
Plugin presets:
|
||||
|
||||
* `@size-limit/preset-app` contains `file` and `time` plugins.
|
||||
* `@size-limit/preset-big-lib` contains `webpack`, `file`, and `time` plugins.
|
||||
* `@size-limit/preset-small-lib` contains `webpack` and `file` plugins.
|
||||
|
||||
[`dual-publish`]: https://github.com/ai/dual-publish
|
||||
|
||||
|
||||
## JS API
|
||||
|
||||
```js
|
||||
const sizeLimit = require('size-limit')
|
||||
const filePlugin = require('@size-limit/file')
|
||||
const webpackPlugin = require('@size-limit/webpack')
|
||||
|
||||
sizeLimit([filePlugin, webpackPlugin], [filePath]).then(result => {
|
||||
result //=> { size: 12480 }
|
||||
})
|
||||
```
|
||||
1086
ui/src/app/services/api/mock-api.service.ts
Normal file
1086
ui/src/app/services/api/mock-api.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
289
ui/src/app/services/api/mock-app-fixures.ts
Normal file
289
ui/src/app/services/api/mock-app-fixures.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { AppStatus } from '../../models/app-model'
|
||||
import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppDependency, BaseApp, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types'
|
||||
export function toAvailablePreview (f: AppAvailableFull): AppAvailablePreview {
|
||||
return {
|
||||
id: f.id,
|
||||
versionInstalled: f.versionInstalled,
|
||||
status: f.status,
|
||||
title: f.title,
|
||||
descriptionShort: f.descriptionShort,
|
||||
iconURL: f.iconURL,
|
||||
versionLatest: f.versionLatest,
|
||||
}
|
||||
}
|
||||
|
||||
export function toInstalledPreview (f: AppInstalledFull): AppInstalledPreview {
|
||||
return {
|
||||
id: f.id,
|
||||
versionInstalled: f.versionInstalled,
|
||||
status: f.status,
|
||||
title: f.title,
|
||||
iconURL: f.iconURL,
|
||||
torAddress: f.torAddress,
|
||||
}
|
||||
}
|
||||
|
||||
export function toServiceRequirement (f: BaseApp, o: Omit<AppDependency, keyof BaseApp>): AppDependency {
|
||||
return {
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
iconURL: f.iconURL,
|
||||
...o,
|
||||
}
|
||||
}
|
||||
|
||||
export function toServiceBreakage (f: BaseApp): DependentBreakage {
|
||||
return {
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
iconURL: f.iconURL,
|
||||
}
|
||||
}
|
||||
|
||||
export const bitcoinI: AppInstalledFull = {
|
||||
id: 'bitcoind',
|
||||
versionInstalled: '0.18.1',
|
||||
title: 'Bitcoin Core',
|
||||
torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion',
|
||||
status: AppStatus.STOPPED,
|
||||
iconURL: 'assets/img/service-icons/bitcoind.png',
|
||||
instructions: 'some instructions',
|
||||
lastBackup: new Date().toISOString(),
|
||||
configuredRequirements: [],
|
||||
hasFetchedFull: true,
|
||||
}
|
||||
|
||||
export const lightningI: AppInstalledFull = {
|
||||
id: 'c-lightning',
|
||||
status: AppStatus.RUNNING,
|
||||
title: 'C Lightning',
|
||||
versionInstalled: '1.0.0',
|
||||
torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion',
|
||||
iconURL: 'assets/img/service-icons/bitwarden.png',
|
||||
instructions: 'some instructions',
|
||||
lastBackup: new Date().toISOString(),
|
||||
configuredRequirements: [
|
||||
toServiceRequirement(bitcoinI,
|
||||
{
|
||||
optional: 'you don\'t reeeeelly need this',
|
||||
default: true,
|
||||
versionSpec: '>= 0.1.2',
|
||||
description: 'lightning needs bitcoin',
|
||||
violation: null,
|
||||
}),
|
||||
],
|
||||
hasFetchedFull: true,
|
||||
}
|
||||
|
||||
export const cupsI: AppInstalledFull = {
|
||||
id: 'cups',
|
||||
versionInstalled: '2.1.0',
|
||||
title: 'Cups Messenger',
|
||||
torAddress: 'sample-cups-tor-address.onion',
|
||||
status: AppStatus.BROKEN_DEPENDENCIES,
|
||||
iconURL: 'assets/img/service-icons/cups.png',
|
||||
|
||||
instructions: 'some instructions',
|
||||
lastBackup: new Date().toISOString(),
|
||||
configuredRequirements: [
|
||||
toServiceRequirement(lightningI,
|
||||
{
|
||||
optional: 'you don\'t reeeeelly need this',
|
||||
default: true,
|
||||
|
||||
versionSpec: '>= 0.1.2',
|
||||
description: 'lightning needs bitcoin',
|
||||
violation: { name: 'incompatible-version' },
|
||||
}),
|
||||
toServiceRequirement(lightningI,
|
||||
{
|
||||
optional: 'you don\'t reeeeelly need this',
|
||||
default: true,
|
||||
|
||||
versionSpec: '>= 0.1.2',
|
||||
description: 'lightning needs bitcoin',
|
||||
violation: { name: 'incompatible-status', status: AppStatus.INSTALLING },
|
||||
}),
|
||||
toServiceRequirement(lightningI,
|
||||
{
|
||||
optional: 'you don\'t reeeeelly need this',
|
||||
default: true,
|
||||
|
||||
versionSpec: '>= 0.1.2',
|
||||
description: 'lightning needs bitcoin',
|
||||
violation: { name: 'incompatible-config', ruleViolations: ['bro', 'seriously', 'fix this'] },
|
||||
}),
|
||||
],
|
||||
hasFetchedFull: true,
|
||||
}
|
||||
|
||||
export const bitcoinA: AppAvailableFull = {
|
||||
id: 'bitcoind',
|
||||
versionLatest: '0.19.1.1',
|
||||
versionInstalled: '0.19.0',
|
||||
status: AppStatus.UNKNOWN,
|
||||
title: 'Bitcoin Core',
|
||||
descriptionShort: 'Bitcoin is an innovative payment network and new kind of money.',
|
||||
iconURL: 'assets/img/service-icons/bitcoind.png',
|
||||
releaseNotes: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus. Segit and more cool things!',
|
||||
descriptionLong: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus.',
|
||||
versions: ['0.19.1.1', '0.19.1', '0.19.0', '0.18.1', '0.17.0'],
|
||||
versionViewing: '0.19.1',
|
||||
serviceRequirements: [],
|
||||
}
|
||||
|
||||
export const lightningA: AppAvailableFull = {
|
||||
id: 'c-lightning',
|
||||
versionLatest: '1.0.1',
|
||||
versionInstalled: null,
|
||||
status: AppStatus.UNKNOWN,
|
||||
title: 'C Lightning',
|
||||
descriptionShort: 'Lightning is quick money things.',
|
||||
iconURL: 'assets/img/service-icons/bitcoind.png',
|
||||
releaseNotes: 'Finally it works',
|
||||
descriptionLong: 'Lightning is an innovative payment network and new kind of money. Lightning utilizes a robust p2p network to garner decentralized consensus.',
|
||||
versions: ['0.0.1', '0.8.0', '0.8.1', '1.0.0', '1.0.1'],
|
||||
versionViewing: '1.0.1',
|
||||
serviceRequirements: [
|
||||
toServiceRequirement(bitcoinA, {
|
||||
optional: null,
|
||||
default: true,
|
||||
versionSpec: '>=0.19.0',
|
||||
description: 'Lightning uses bitcoin under the hood',
|
||||
violation: null,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
export const btcPayA: AppAvailableFull = {
|
||||
id: 'btcPay',
|
||||
versionLatest: '1.0.1',
|
||||
versionInstalled: '1.0.1',
|
||||
status: AppStatus.INSTALLING,
|
||||
title: 'BTC Pay',
|
||||
descriptionShort: 'BTC Pay is quick payment money things',
|
||||
iconURL: 'assets/img/service-icons/bitcoind.png',
|
||||
releaseNotes: 'Finally pay us Finally pay us Finally pay us Finally pay us Finally pay usFinally pay us',
|
||||
descriptionLong: 'Btc Pay is an innovative payment network and new kind of money. Btc Pay utilizes a robust p2p network to garner decentralized consensus.',
|
||||
versions: ['0.8.0', '0.8.1', '1.0.0', '1.0.1'],
|
||||
versionViewing: '1.0.1',
|
||||
serviceRequirements: [
|
||||
toServiceRequirement(bitcoinA, {
|
||||
optional: null,
|
||||
default: true,
|
||||
versionSpec: '>0.19.0',
|
||||
description: 'Lightning uses bitcoin under the hood',
|
||||
violation: { name: 'incompatible-version' },
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
export const thunderA: AppAvailableFull = {
|
||||
id: 'thunder',
|
||||
versionLatest: '1.0.1',
|
||||
versionInstalled: null,
|
||||
status: AppStatus.UNKNOWN,
|
||||
title: 'Thunder',
|
||||
descriptionShort: 'Thunder is quick payment money things',
|
||||
iconURL: 'assets/img/service-icons/bitcoind.png',
|
||||
releaseNotes: 'Finally pay us',
|
||||
descriptionLong: 'Thunder is an innovative payment network and new kind of money. Thunder utilizes a robust p2p network to garner decentralized consensus.',
|
||||
versions: ['0.8.0', '0.8.1', '1.0.0', '1.0.1'],
|
||||
versionViewing: '1.0.1',
|
||||
serviceRequirements: [
|
||||
toServiceRequirement(bitcoinA, {
|
||||
optional: null,
|
||||
default: true,
|
||||
versionSpec: '>0.19.0',
|
||||
description: 'Thunder uses bitcoin under the hood',
|
||||
violation: { name: 'incompatible-version' },
|
||||
}),
|
||||
toServiceRequirement(lightningA, {
|
||||
optional: null,
|
||||
default: true,
|
||||
versionSpec: '>=1.0.1',
|
||||
description: 'Thunder uses lightning under the hood',
|
||||
violation: { name: 'incompatible-version' },
|
||||
}),
|
||||
toServiceRequirement(btcPayA, {
|
||||
optional: 'Can be configured to use chase bank instead',
|
||||
default: true,
|
||||
versionSpec: '>=1.0.1',
|
||||
description: 'Thunder can use btcpay under the hood',
|
||||
violation: { name: 'missing' },
|
||||
}),
|
||||
toServiceRequirement(btcPayA, {
|
||||
optional: 'Can be configured to use chase bank instead',
|
||||
default: true,
|
||||
versionSpec: '>=1.0.1',
|
||||
description: 'Thunder can use btcpay under the hood',
|
||||
violation: { name: 'incompatible-status', status: AppStatus.INSTALLING },
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
export const cupsA: AppAvailableFull = {
|
||||
id: 'cups',
|
||||
versionLatest: '2.1.0',
|
||||
versionInstalled: '2.1.0',
|
||||
status: AppStatus.RUNNING,
|
||||
title: 'Cups Messenger',
|
||||
descriptionShort: 'P2P encrypted messaging over Tor.',
|
||||
iconURL: 'assets/img/service-icons/cups.png',
|
||||
releaseNotes: 'Segit and more cool things!',
|
||||
descriptionLong: 'Bitcoin is an innovative payment network and new kind of money. Bitcoin utilizes a robust p2p network to garner decentralized consensus.',
|
||||
versions: ['0.1.0', '0.1.1', '0.1.2', '1.0.0', '2.0.0', '2.1.0'],
|
||||
versionViewing: '2.1.0',
|
||||
serviceRequirements: [],
|
||||
}
|
||||
|
||||
export const bitwardenA: AppAvailableFull = {
|
||||
id: 'bitwarden',
|
||||
versionLatest: '0.1.1',
|
||||
versionInstalled: null,
|
||||
status: null,
|
||||
title: 'Bitwarden',
|
||||
descriptionShort: `Self-hosted password manager`,
|
||||
iconURL: 'assets/img/service-icons/bitwarden.png',
|
||||
releaseNotes: 'Passwords and shite!',
|
||||
descriptionLong: 'Bitwarden is fun.',
|
||||
versions: ['0.19.0', '0.18.1', '0.17.0'],
|
||||
versionViewing: '0.1.1',
|
||||
serviceRequirements: [
|
||||
toServiceRequirement(cupsA, {
|
||||
optional: 'Can be configured to use chase bank instead',
|
||||
default: true,
|
||||
versionSpec: '>=1.0.0',
|
||||
description: 'cups does great stuff for bitwarden',
|
||||
violation: { name: 'incompatible-config', ruleViolations: ['change this value to that value', 'change this second value to something better']},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
export const mockApiAppAvailableFull: { [appId: string]: AppAvailableFull; } = {
|
||||
bitcoind: bitcoinA,
|
||||
lightning: lightningA,
|
||||
btcPay: btcPayA,
|
||||
thunder: thunderA,
|
||||
cups: cupsA,
|
||||
bitwarden: bitwardenA,
|
||||
}
|
||||
|
||||
export const mockApiAppInstalledFull: { [appId: string]: AppInstalledFull; } = {
|
||||
bitcoind: bitcoinI,
|
||||
cups: cupsI,
|
||||
lightning: lightningI,
|
||||
}
|
||||
|
||||
export const mockApiAppAvailableVersionInfo: AppAvailableVersionSpecificInfo = {
|
||||
releaseNotes: 'Some older release notes that are not super important anymore.',
|
||||
serviceRequirements: [],
|
||||
versionViewing: '0.2.0',
|
||||
}
|
||||
|
||||
export const mockAppDependentBreakages: { breakages: DependentBreakage[] } = {
|
||||
breakages: [
|
||||
toServiceBreakage(bitcoinI),
|
||||
toServiceBreakage(cupsA),
|
||||
],
|
||||
}
|
||||
62
ui/src/app/services/auth.service.ts
Normal file
62
ui/src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { BehaviorSubject, Subscription } from 'rxjs'
|
||||
import { distinctUntilChanged } from 'rxjs/operators'
|
||||
import { ApiService } from './api/api.service'
|
||||
import { chill } from '../util/misc.util'
|
||||
import { isUnauthorized } from '../util/web.util'
|
||||
import { Storage } from '@ionic/storage'
|
||||
import { StorageKeys } from '../models/storage-keys'
|
||||
|
||||
export enum AuthState {
|
||||
UNVERIFIED,
|
||||
VERIFIED,
|
||||
INITIALIZING,
|
||||
}
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly $authState$: BehaviorSubject<AuthState> = new BehaviorSubject(AuthState.INITIALIZING)
|
||||
|
||||
constructor (
|
||||
private readonly api: ApiService,
|
||||
private readonly storage: Storage,
|
||||
) { }
|
||||
|
||||
peek (): AuthState { return this.$authState$.getValue() }
|
||||
listen (callback: Partial<{ [k in AuthState]: () => any }>): Subscription {
|
||||
return this.$authState$.pipe(distinctUntilChanged()).subscribe(s => {
|
||||
return (callback[s] || chill)()
|
||||
})
|
||||
}
|
||||
|
||||
async login (password: string) {
|
||||
try {
|
||||
await this.api.postLogin(password)
|
||||
await this.storage.set(StorageKeys.LOGGED_IN_KEY, true)
|
||||
this.$authState$.next(AuthState.VERIFIED)
|
||||
} catch (e) {
|
||||
if (isUnauthorized(e)) {
|
||||
this.$authState$.next(AuthState.UNVERIFIED)
|
||||
throw { name: 'invalid', message: 'invalid credentials' }
|
||||
}
|
||||
console.error(`Failed login attempt`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async restoreCache (): Promise<AuthState> {
|
||||
const loggedIn = await this.storage.get(StorageKeys.LOGGED_IN_KEY)
|
||||
if (loggedIn) {
|
||||
this.$authState$.next(AuthState.VERIFIED)
|
||||
return AuthState.VERIFIED
|
||||
} else {
|
||||
this.$authState$.next(AuthState.UNVERIFIED)
|
||||
return AuthState.UNVERIFIED
|
||||
}
|
||||
}
|
||||
|
||||
async setAuthStateUnverified (): Promise<void> {
|
||||
this.$authState$.next(AuthState.UNVERIFIED)
|
||||
}
|
||||
}
|
||||
33
ui/src/app/services/config.service.ts
Normal file
33
ui/src/app/services/config.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigService {
|
||||
origin = removePort(removeProtocol(window.origin))
|
||||
version = require('../../../package.json').version
|
||||
|
||||
api = {
|
||||
useMocks: require('../../../use-mocks.json').useMocks,
|
||||
url: '/api',
|
||||
version: '/v0',
|
||||
root: '', // empty will default to same origin
|
||||
}
|
||||
|
||||
isConsulateIos = window['platform'] === 'ios'
|
||||
isConsulateAndroid = window['platform'] === 'android'
|
||||
|
||||
isTor () : boolean {
|
||||
return this.api.useMocks || this.origin.endsWith('.onion')
|
||||
}
|
||||
}
|
||||
|
||||
function removeProtocol (str: string): string {
|
||||
if (str.startsWith('http://')) return str.slice(7)
|
||||
if (str.startsWith('https://')) return str.slice(8)
|
||||
return str
|
||||
}
|
||||
|
||||
function removePort (str: string): string {
|
||||
return str.split(':')[0]
|
||||
}
|
||||
21
ui/src/app/services/emver.service.ts
Normal file
21
ui/src/app/services/emver.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Emver {
|
||||
private e: typeof import('@start9labs/emver')
|
||||
constructor () { }
|
||||
|
||||
async init () {
|
||||
this.e = await import('@start9labs/emver')
|
||||
}
|
||||
|
||||
compare (lhs: string, rhs: string): number {
|
||||
return this.e.compare(lhs, rhs)
|
||||
}
|
||||
|
||||
satisfies (version: string, range: string): boolean {
|
||||
return this.e.satisfies(version, range)
|
||||
}
|
||||
}
|
||||
118
ui/src/app/services/http.service.ts
Normal file
118
ui/src/app/services/http.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
|
||||
import { Observable, from, interval, race } from 'rxjs'
|
||||
import { map, take } from 'rxjs/operators'
|
||||
import { ConfigService } from './config.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class HttpService {
|
||||
constructor (
|
||||
private readonly http: HttpClient,
|
||||
private readonly config: ConfigService,
|
||||
) { }
|
||||
|
||||
async serverRequest<T> (options: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
|
||||
options.url = leadingSlash(`${this.config.api.url}${exists(overrides.version) ? overrides.version : this.config.api.version}${options.url}`)
|
||||
if ( this.config.api.root && this.config.api.root !== '' ) {
|
||||
options.url = `${this.config.api.root}${options.url}`
|
||||
}
|
||||
return this.request<T>(options)
|
||||
}
|
||||
|
||||
async request<T> (httpOpts: HttpOptions): Promise<T> {
|
||||
const { url, body, timeout, ...rest} = translateOptions(httpOpts)
|
||||
let req: Observable<{ body: T }>
|
||||
switch (httpOpts.method){
|
||||
case Method.GET: req = this.http.get(url, rest) as any; break
|
||||
case Method.POST: req = this.http.post(url, body, rest) as any; break
|
||||
case Method.PUT: req = this.http.put(url, body, rest) as any; break
|
||||
case Method.PATCH: req = this.http.patch(url, body, rest) as any; break
|
||||
case Method.DELETE: req = this.http.delete(url, rest) as any; break
|
||||
}
|
||||
|
||||
return (timeout ? withTimeout(req, timeout) : req)
|
||||
.toPromise()
|
||||
.then(res => res.body)
|
||||
.catch(e => { console.error(e); throw humanReadableErrorMessage(e)})
|
||||
}
|
||||
}
|
||||
|
||||
function humanReadableErrorMessage (e: any): Error {
|
||||
// server up, custom backend error
|
||||
if (e.error && e.error.message) return { ...e, message: e.error.message }
|
||||
if (e.message) return { ...e, message: e.message }
|
||||
if (e.status && e.statusText) return { ...e, message: `${e.status} ${e.statusText}` }
|
||||
return { ...e, message: `Unidentifiable HTTP exception` }
|
||||
}
|
||||
|
||||
function leadingSlash (url: string): string {
|
||||
let toReturn = url
|
||||
toReturn = toReturn.startsWith('/') ? toReturn : '/' + toReturn
|
||||
toReturn = !toReturn.endsWith('/') ? toReturn : toReturn.slice(0, -1)
|
||||
return toReturn
|
||||
}
|
||||
|
||||
export enum Method {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
PATCH = 'PATCH',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface HttpOptions {
|
||||
withCredentials?: boolean
|
||||
url: string
|
||||
method: Method
|
||||
params?: {
|
||||
[param: string]: string | string[];
|
||||
}
|
||||
data?: any
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
}
|
||||
readTimeout?: number
|
||||
}
|
||||
|
||||
export interface HttpJsonOptions {
|
||||
headers?: HttpHeaders | {
|
||||
[header: string]: string | string[];
|
||||
}
|
||||
observe: 'events'
|
||||
params?: HttpParams | {
|
||||
[param: string]: string | string[];
|
||||
}
|
||||
reportProgress?: boolean
|
||||
responseType?: 'json'
|
||||
withCredentials?: boolean
|
||||
body?: any
|
||||
url: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
function translateOptions (httpOpts: HttpOptions): HttpJsonOptions {
|
||||
return {
|
||||
observe: 'events',
|
||||
responseType: 'json',
|
||||
reportProgress: false,
|
||||
withCredentials: true,
|
||||
headers: httpOpts.headers,
|
||||
params: httpOpts.params,
|
||||
body: httpOpts.data || { },
|
||||
url: httpOpts.url,
|
||||
timeout: httpOpts.readTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<U> (req: Observable<U>, timeout: number): Observable<U> {
|
||||
return race(
|
||||
from(req.toPromise()), // this guarantees it only emits on completion, intermediary emissions are suppressed.
|
||||
interval(timeout).pipe(take(1), map(() => { throw new Error('timeout') })),
|
||||
)
|
||||
}
|
||||
|
||||
function exists (str?: string): boolean {
|
||||
return !!str || str === ''
|
||||
}
|
||||
86
ui/src/app/services/loader.service.ts
Normal file
86
ui/src/app/services/loader.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { concatMap, finalize } from 'rxjs/operators'
|
||||
import { Observable, from, Subject } from 'rxjs'
|
||||
import { fromAsync$, fromAsyncP, emitAfter$, fromSync$ } from '../util/rxjs.util'
|
||||
import { LoadingController } from '@ionic/angular'
|
||||
import { LoadingOptions } from '@ionic/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LoaderService {
|
||||
private loadingOptions: LoadingOptions = defaultOptions()
|
||||
constructor (private readonly loadingCtrl: LoadingController) { }
|
||||
|
||||
private loader: HTMLIonLoadingElement
|
||||
|
||||
public get ionLoader (): HTMLIonLoadingElement {
|
||||
return this.loader
|
||||
}
|
||||
|
||||
public get ctrl () {
|
||||
return this.loadingCtrl
|
||||
}
|
||||
|
||||
private setOptions (l: LoadingOptions): LoaderService {
|
||||
this.loadingOptions = l
|
||||
return this
|
||||
}
|
||||
|
||||
of (overrideOptions: LoadingOptions): LoaderService {
|
||||
return new LoaderService(this.loadingCtrl).setOptions(Object.assign(defaultOptions(), overrideOptions))
|
||||
}
|
||||
|
||||
displayDuring$<T> (o: Observable<T>): Observable<T> {
|
||||
let shouldDisplay = true
|
||||
const displayIfItsBeenAtLeast = 10 // ms
|
||||
return fromAsync$(
|
||||
async () => {
|
||||
this.loader = await this.loadingCtrl.create(this.loadingOptions)
|
||||
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldDisplay) this.loader.present() })
|
||||
},
|
||||
).pipe(
|
||||
concatMap(() => o),
|
||||
finalize(() => {
|
||||
this.loader.dismiss(); shouldDisplay = false; this.loader = undefined
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
displayDuringP<T> (p: Promise<T>): Promise<T> {
|
||||
return this.displayDuring$(from(p)).toPromise()
|
||||
}
|
||||
|
||||
displayDuringAsync<T> (thunk: () => Promise<T>): Promise<T> {
|
||||
return this.displayDuringP(fromAsyncP(thunk))
|
||||
}
|
||||
}
|
||||
|
||||
export function markAsLoadingDuring$<T> ($trigger$: Subject<boolean>, o: Observable<T>): Observable<T> {
|
||||
let shouldBeOn = true
|
||||
const displayIfItsBeenAtLeast = 5 // ms
|
||||
return fromSync$(() => {
|
||||
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldBeOn) $trigger$.next(true) })
|
||||
}).pipe(
|
||||
concatMap(() => o),
|
||||
finalize(() => {
|
||||
$trigger$.next(false)
|
||||
shouldBeOn = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function markAsLoadingDuringP<T> ($trigger$: Subject<boolean>, p: Promise<T>): Promise<T> {
|
||||
return markAsLoadingDuring$($trigger$, from(p)).toPromise()
|
||||
}
|
||||
|
||||
export function markAsLoadingDuringAsync<T> ($trigger$: Subject<boolean>, thunk: () => Promise<T>): Promise<T> {
|
||||
return markAsLoadingDuringP($trigger$, fromAsyncP(thunk))
|
||||
}
|
||||
|
||||
|
||||
const defaultOptions: () => LoadingOptions = () => ({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
backdropDismiss: true,
|
||||
})
|
||||
23
ui/src/app/services/pwa-back.service.ts
Normal file
23
ui/src/app/services/pwa-back.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from '@angular/router'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PwaBackService {
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly nav: NavController,
|
||||
) { }
|
||||
|
||||
// this will strip an entry from the path on navigation
|
||||
back () {
|
||||
return this.nav.back()
|
||||
// this.router.navigate()
|
||||
// const path = this.router.url.split('/').filter(a => a !== '')
|
||||
// path.pop()
|
||||
// this.router.navigate(['/', ...path], { replaceUrl: false })
|
||||
}
|
||||
}
|
||||
|
||||
114
ui/src/app/services/server-config.service.ts
Normal file
114
ui/src/app/services/server-config.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { AppConfigValuePage } from '../modals/app-config-value/app-config-value.page'
|
||||
import { ApiService } from './api/api.service'
|
||||
import { PropertySubject } from '../util/property-subject.util'
|
||||
import { S9Server, ServerModel } from '../models/server-model'
|
||||
import { ValueSpec } from '../app-config/config-types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ServerConfigService {
|
||||
server: PropertySubject<S9Server>
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly serverModel: ServerModel,
|
||||
) {
|
||||
this.server = this.serverModel.watch()
|
||||
}
|
||||
|
||||
async presentModalValueEdit (key: string, add = false) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
backdropDismiss: false,
|
||||
component: AppConfigValuePage,
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
componentProps: {
|
||||
...this.getConfigSpec(key),
|
||||
value: add ? '' : this.server[key].getValue(),
|
||||
},
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private getConfigSpec (key: string): SpecAndSaveFn {
|
||||
const configSpec: { [key: string]: SpecAndSaveFn } = {
|
||||
name: {
|
||||
spec: {
|
||||
type: 'string',
|
||||
name: 'Device Name',
|
||||
description: 'A unique label for this device.',
|
||||
nullable: false,
|
||||
// @TODO determine regex
|
||||
// pattern: '',
|
||||
patternDescription: 'Must be less than 40 characters',
|
||||
masked: false,
|
||||
copyable: true,
|
||||
},
|
||||
saveFn: (val: string) => {
|
||||
return this.apiService.patchServerConfig('name', val).then(() => this.serverModel.update({ name: val }))
|
||||
},
|
||||
},
|
||||
// password: {
|
||||
// spec: {
|
||||
// type: 'string',
|
||||
// name: 'Change Password',
|
||||
// description: 'The master password for your Embassy. Must contain at least 128 bits of entropy.',
|
||||
// nullable: false,
|
||||
// // @TODO figure out how to confirm min entropy
|
||||
// // pattern: '^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[*.!@#$%^&*\]).{12,32}$',
|
||||
// patternDescription: 'Password too simple. Password must contain at least 128 bits of entroy.',
|
||||
// changeWarning: 'Changing your password will have no affect on old backups. In order to restore old backups, you must provide the password that was used to create them.',
|
||||
// masked: true,
|
||||
// copyable: true,
|
||||
// },
|
||||
// saveFn: (val: string) => {
|
||||
// return this.apiService.patchServerConfig('password', val)
|
||||
// },
|
||||
// },
|
||||
// alternativeRegistryUrl: {
|
||||
// spec: {
|
||||
// type: 'string',
|
||||
// name: 'Marketplace URL',
|
||||
// description: 'Used for connecting to an alternative service marketplace.',
|
||||
// nullable: true,
|
||||
// // @TODO regex for URL
|
||||
// // pattern: '',
|
||||
// patternDescription: 'Must be a valid URL',
|
||||
// changeWarning: 'Downloading services from an alternative marketplace could result in malicious or harmful code being installed on your device.',
|
||||
// masked: false,
|
||||
// copyable: true,
|
||||
// },
|
||||
// saveFn: (val: string) => {
|
||||
// return this.apiService.patchServerConfig('alternativeRegistryUrl', val).then(() => this.serverModel.update({ alternativeRegistryUrl: val }))
|
||||
// },
|
||||
// },
|
||||
ssh: {
|
||||
spec: {
|
||||
type: 'string',
|
||||
name: 'SSH Key',
|
||||
description: 'Add SSH keys to your Embassy to gain root access from the command line.',
|
||||
nullable: false,
|
||||
// @TODO regex for SSH Key
|
||||
// pattern: '',
|
||||
patternDescription: 'Must be a valid SSH key',
|
||||
masked: true,
|
||||
copyable: true,
|
||||
},
|
||||
saveFn: (val: string) => {
|
||||
return this.apiService.addSSHKey(val)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return configSpec[key]
|
||||
}
|
||||
}
|
||||
|
||||
interface SpecAndSaveFn {
|
||||
spec: ValueSpec
|
||||
saveFn: (val: string) => Promise<any>
|
||||
}
|
||||
10
ui/src/app/services/split-pane.service.ts
Normal file
10
ui/src/app/services/split-pane.service.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SplitPaneTracker {
|
||||
$menuFixedOpenOnLeft$: BehaviorSubject<boolean> = new BehaviorSubject(false)
|
||||
constructor () { }
|
||||
}
|
||||
50
ui/src/app/services/sync.notifier.ts
Normal file
50
ui/src/app/services/sync.notifier.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToastController, NavController } from '@ionic/angular'
|
||||
import { ServerModel, S9Server } from '../models/server-model'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SyncNotifier {
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly serverModel: ServerModel,
|
||||
) { }
|
||||
|
||||
async handleNotifications (server: Readonly<S9Server>): Promise<void> {
|
||||
const count = server.notifications.length
|
||||
|
||||
if (!count) { return }
|
||||
|
||||
let updates = { } as Partial<S9Server>
|
||||
updates.badge = server.badge + count
|
||||
updates.notifications = []
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: 'Embassy',
|
||||
message: `${count} new notification${count === 1 ? '' : 's'}`,
|
||||
position: 'bottom',
|
||||
duration: 4000,
|
||||
cssClass: 'notification-toast',
|
||||
buttons: [
|
||||
{
|
||||
side: 'start',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
side: 'end',
|
||||
text: 'View',
|
||||
handler: () => {
|
||||
this.navCtrl.navigateForward(['/notifications'])
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await toast.present()
|
||||
this.serverModel.update(updates)
|
||||
}
|
||||
}
|
||||
79
ui/src/app/services/sync.service.ts
Normal file
79
ui/src/app/services/sync.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ServerModel } from '../models/server-model'
|
||||
import { ApiService } from './api/api.service'
|
||||
import { tryAll, pauseFor } from '../util/misc.util'
|
||||
import { AppModel } from '../models/app-model'
|
||||
import { SyncNotifier } from './sync.notifier'
|
||||
import { BehaviorSubject, Observable, of, from, Subject, EMPTY } from 'rxjs'
|
||||
import { switchMap, concatMap, catchError, delay, tap } from 'rxjs/operators'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SyncDaemon {
|
||||
private readonly syncInterval = 5000
|
||||
private readonly $sync$ = new BehaviorSubject(false)
|
||||
|
||||
// emits on every successful sync
|
||||
private readonly $synced$ = new Subject<void>()
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly syncNotifier: SyncNotifier,
|
||||
) {
|
||||
this.$sync$.pipe(
|
||||
switchMap(go => go
|
||||
? this.sync().pipe(delay(this.syncInterval), tap(() => this.$sync$.next(true)))
|
||||
: EMPTY,
|
||||
),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
start () { this.$sync$.next(true) }
|
||||
stop () { this.$sync$.next(false) }
|
||||
sync (): Observable<void> {
|
||||
return from(this.getServerAndApps()).pipe(
|
||||
concatMap(() => this.syncNotifier.handleNotifications(this.serverModel.peek())),
|
||||
tap(() => this.$synced$.next()),
|
||||
catchError(e => of(console.error(`Exception in sync service`, e))),
|
||||
)
|
||||
}
|
||||
|
||||
watchSynced (): Observable<void> {
|
||||
return this.$synced$.asObservable()
|
||||
}
|
||||
|
||||
private async getServerAndApps (): Promise<void> {
|
||||
const now = new Date()
|
||||
const [serverRes, appsRes] = await tryAll([
|
||||
this.apiService.getServer(),
|
||||
pauseFor(250).then(() => this.apiService.getInstalledApps()),
|
||||
])
|
||||
|
||||
switch (serverRes.result) {
|
||||
case 'resolve': {
|
||||
this.serverModel.update(serverRes.value, now)
|
||||
break
|
||||
}
|
||||
case 'reject': {
|
||||
console.error(`get server request rejected with`, serverRes.value)
|
||||
this.serverModel.markUnreachable()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch (appsRes.result) {
|
||||
case 'resolve': {
|
||||
this.appModel.syncCache(appsRes.value, now)
|
||||
break
|
||||
}
|
||||
case 'reject': {
|
||||
console.error(`get apps request rejected with`, appsRes.value)
|
||||
this.appModel.markAppsUnreachable()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
ui/src/app/services/tracking-modal-controller.service.ts
Normal file
70
ui/src/app/services/tracking-modal-controller.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ModalOptions } from '@ionic/core'
|
||||
import { APP_CONFIG_COMPONENT_MAPPING } from '../modals/app-config-injectable/modal-injectable-token'
|
||||
import { AppConfigComponentMapping } from '../modals/app-config-injectable/modal-injectable-type'
|
||||
import { ValueSpec } from '../app-config/config-types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TrackingModalController {
|
||||
private modals: { [modalId: string] : HTMLIonModalElement} = { }
|
||||
|
||||
private readonly $onDismiss$ = new Subject<string>()
|
||||
private readonly $onCreate$ = new Subject<string>()
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
@Inject(APP_CONFIG_COMPONENT_MAPPING) private readonly appConfigComponentMapping: AppConfigComponentMapping,
|
||||
) { }
|
||||
|
||||
async createConfigModal (o: Omit<ModalOptions, 'component'>, type: ValueSpec['type']) {
|
||||
const component = this.appConfigComponentMapping[type]
|
||||
return this.create({ ...o, component })
|
||||
}
|
||||
|
||||
async create (a: ModalOptions): Promise<HTMLIonModalElement> {
|
||||
const modal = await this.modalCtrl.create(a)
|
||||
this.modals[modal.id] = modal
|
||||
this.$onCreate$.next(modal.id)
|
||||
|
||||
modal.onWillDismiss().then(() => {
|
||||
delete this.modals[modal.id]
|
||||
this.$onDismiss$.next(modal.id)
|
||||
})
|
||||
return modal
|
||||
}
|
||||
|
||||
dismissAll (): Promise<boolean[]> {
|
||||
return Promise.all(
|
||||
Object.values(this.modals).map(m => m.dismiss()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
dismiss (val?: any): Promise<boolean> {
|
||||
return this.modalCtrl.dismiss(val)
|
||||
}
|
||||
|
||||
onCreateAny$ (): Observable<string> {
|
||||
return this.$onCreate$.asObservable()
|
||||
}
|
||||
|
||||
onDismissAny$ (): Observable<string> {
|
||||
return this.$onDismiss$.asObservable()
|
||||
}
|
||||
|
||||
async getTop (): Promise<HTMLIonModalElement> {
|
||||
return this.modalCtrl.getTop()
|
||||
}
|
||||
|
||||
get anyModals (): boolean {
|
||||
return Object.keys(this.modals).length !== 0
|
||||
}
|
||||
|
||||
get modalCount (): number {
|
||||
return Object.keys(this.modals).length
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user