mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
0.3.0 refactor
ui: adds overlay layer to patch-db-client ui: getting towards mocks ui: cleans up factory init ui: nice type hack ui: live api for patch ui: api service source + http starts up ui: api source + http ui: rework patchdb config, pass stashTimeout into patchDbModel wires in temp patching into api service ui: example of wiring patchdbmodel into page begin integration remove unnecessary method linting first data rendering rework app initialization http source working for ssh delete call temp patches working entire Embassy tab complete not in kansas anymore ripping, saving progress progress for API request response types and endoint defs Update data-model.ts shambles, but in a good way progress big progress progress installed list working big progress progress progress begin marketplace redesign Update api-types.ts Update api-types.ts marketplace improvements cosmetic dependencies and recommendations begin nym auth approach install wizard restore flow and donations
This commit is contained in:
committed by
Aiden McClelland
parent
fd685ae32c
commit
594d93eb3b
@@ -1,177 +1,153 @@
|
||||
import { MapSubject, Delta, Update } from '../util/map-subject.util'
|
||||
import { diff, partitionArray } from '../util/misc.util'
|
||||
import { PropertySubject, complete } from '../util/property-subject.util'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { merge, Observable, of } from 'rxjs'
|
||||
import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
|
||||
import { Storage } from '@ionic/storage'
|
||||
import { StorageKeys } from './storage-keys'
|
||||
import { AppInstalledFull, AppInstalledPreview } from './app-types'
|
||||
// import { MapSubject, Delta, Update } from '../util/map-subject.util'
|
||||
// import { diff, partitionArray } from '../util/misc.util'
|
||||
// import { Injectable } from '@angular/core'
|
||||
// import { merge, Observable, of } from 'rxjs'
|
||||
// import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
|
||||
// import { Storage } from '@ionic/storage'
|
||||
// import { StorageKeys } from './storage-keys'
|
||||
// import { AppInstalledFull, AppInstalledPreview } from './app-types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AppModel extends MapSubject<AppInstalledFull> {
|
||||
// hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
|
||||
hasLoaded = false
|
||||
lastUpdatedAt: { [id: string]: Date } = { }
|
||||
constructor (private readonly storage: Storage) {
|
||||
super()
|
||||
// 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
|
||||
// Process continues as long as deltas fire.
|
||||
this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
|
||||
this.commitCache()
|
||||
})
|
||||
}
|
||||
// @Injectable({
|
||||
// providedIn: 'root',
|
||||
// })
|
||||
// export class AppModel extends MapSubject<AppInstalledFull> {
|
||||
// // hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
|
||||
// hasLoaded = false
|
||||
// lastUpdatedAt: { [id: string]: Date } = { }
|
||||
// constructor (private readonly storage: Storage) {
|
||||
// super()
|
||||
// // 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
|
||||
// // Process continues as long as deltas fire.
|
||||
// this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
|
||||
// this.commitCache()
|
||||
// })
|
||||
// }
|
||||
|
||||
update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
|
||||
this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
|
||||
if (this.lastUpdatedAt[newValues.id] > timestamp) {
|
||||
return
|
||||
} else {
|
||||
super.update(newValues)
|
||||
this.lastUpdatedAt[newValues.id] = timestamp
|
||||
}
|
||||
}
|
||||
// update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
|
||||
// this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
|
||||
// if (this.lastUpdatedAt[newValues.id] > timestamp) {
|
||||
// return
|
||||
// } else {
|
||||
// super.update(newValues)
|
||||
// this.lastUpdatedAt[newValues.id] = timestamp
|
||||
// }
|
||||
// }
|
||||
|
||||
// client fxns
|
||||
watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
|
||||
return filterFor
|
||||
? this.$delta$.pipe(filter(d => d.action === filterFor))
|
||||
: this.$delta$.asObservable()
|
||||
}
|
||||
// // client fxns
|
||||
// watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
|
||||
// return filterFor
|
||||
// ? this.$delta$.pipe(filter(d => d.action === filterFor))
|
||||
// : this.$delta$.asObservable()
|
||||
// }
|
||||
|
||||
watch (appId: string) : PropertySubject<AppInstalledFull> {
|
||||
const toReturn = super.watch(appId)
|
||||
if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
|
||||
return toReturn
|
||||
}
|
||||
// watch (appId: string) : PropertySubject<AppInstalledFull> {
|
||||
// const toReturn = super.watch(appId)
|
||||
// if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
|
||||
// return toReturn
|
||||
// }
|
||||
|
||||
// when an app is installing
|
||||
watchForInstallation (appId: string): Observable<string | undefined> {
|
||||
const toWatch = super.watch(appId)
|
||||
if (!toWatch) return of(undefined)
|
||||
// // when an app is installing
|
||||
// watchForInstallation (appId: string): Observable<string | undefined> {
|
||||
// const toWatch = super.watch(appId)
|
||||
// if (!toWatch) return of(undefined)
|
||||
|
||||
return toWatch.status.pipe(
|
||||
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
pairwise(),
|
||||
filter( ([old, _]) => old === AppStatus.INSTALLING ),
|
||||
take(1),
|
||||
mapTo(appId),
|
||||
)
|
||||
}
|
||||
// return toWatch.status.pipe(
|
||||
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
// pairwise(),
|
||||
// filter( ([old, _]) => old === AppStatus.INSTALLING ),
|
||||
// take(1),
|
||||
// mapTo(appId),
|
||||
// )
|
||||
// }
|
||||
|
||||
// TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
|
||||
watchForBackup (appId: string): Observable<string | undefined> {
|
||||
const toWatch = super.watch(appId)
|
||||
if (!toWatch) return of(undefined)
|
||||
// // TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
|
||||
// watchForBackup (appId: string): Observable<string | undefined> {
|
||||
// const toWatch = super.watch(appId)
|
||||
// if (!toWatch) return of(undefined)
|
||||
|
||||
return toWatch.status.pipe(
|
||||
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
pairwise(),
|
||||
filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
|
||||
take(1),
|
||||
mapTo(appId),
|
||||
)
|
||||
}
|
||||
// return toWatch.status.pipe(
|
||||
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
// pairwise(),
|
||||
// filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
|
||||
// take(1),
|
||||
// mapTo(appId),
|
||||
// )
|
||||
// }
|
||||
|
||||
watchForInstallations (appIds: { id: string }[]): Observable<string> {
|
||||
return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
|
||||
filter(t => !!t),
|
||||
)
|
||||
}
|
||||
// watchForInstallations (appIds: { id: string }[]): Observable<string> {
|
||||
// return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
|
||||
// filter(t => !!t),
|
||||
// )
|
||||
// }
|
||||
|
||||
// cache mgmt
|
||||
clear (): void {
|
||||
this.ids.forEach(id => {
|
||||
complete(this.contents[id] || { } as PropertySubject<any>)
|
||||
delete this.contents[id]
|
||||
})
|
||||
this.hasLoaded = false
|
||||
this.contents = { }
|
||||
this.lastUpdatedAt = { }
|
||||
}
|
||||
// // cache mgmt
|
||||
// clear (): void {
|
||||
// this.ids.forEach(id => {
|
||||
// complete(this.contents[id] || { } as PropertySubject<any>)
|
||||
// delete this.contents[id]
|
||||
// })
|
||||
// this.hasLoaded = false
|
||||
// this.contents = { }
|
||||
// this.lastUpdatedAt = { }
|
||||
// }
|
||||
|
||||
private commitCache (): Promise<void> {
|
||||
return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
|
||||
}
|
||||
// private commitCache (): Promise<void> {
|
||||
// return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
|
||||
// }
|
||||
|
||||
async restoreCache (): Promise<void> {
|
||||
const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
|
||||
console.log(`restored app cache`, stored)
|
||||
if (stored) this.hasLoaded = true
|
||||
return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
|
||||
}
|
||||
// async restoreCache (): Promise<void> {
|
||||
// const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
|
||||
// console.log(`restored app cache`, stored)
|
||||
// if (stored) this.hasLoaded = true
|
||||
// return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
|
||||
// }
|
||||
|
||||
upsertAppFull (app: AppInstalledFull): void {
|
||||
this.update(app)
|
||||
}
|
||||
// upsertAppFull (app: AppInstalledFull): void {
|
||||
// this.update(app)
|
||||
// }
|
||||
|
||||
// synchronizers
|
||||
upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
|
||||
const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
|
||||
updates.map(u => this.update(u, timestamp))
|
||||
creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
|
||||
}
|
||||
// // synchronizers
|
||||
// upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
|
||||
// const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
|
||||
// updates.map(u => this.update(u, timestamp))
|
||||
// creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
|
||||
// }
|
||||
|
||||
syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
|
||||
this.hasLoaded = true
|
||||
this.deleteNonexistentApps(upToDateApps)
|
||||
this.upsertApps(upToDateApps, timestamp)
|
||||
}
|
||||
// syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
|
||||
// this.hasLoaded = true
|
||||
// this.deleteNonexistentApps(upToDateApps)
|
||||
// this.upsertApps(upToDateApps, timestamp)
|
||||
// }
|
||||
|
||||
private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
|
||||
const currentAppIds = apps.map(a => a.id)
|
||||
const previousAppIds = Object.keys(this.contents)
|
||||
const appsToDelete = diff(previousAppIds, currentAppIds)
|
||||
appsToDelete.map(appId => this.delete(appId))
|
||||
}
|
||||
// private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
|
||||
// const currentAppIds = apps.map(a => a.id)
|
||||
// const previousAppIds = Object.keys(this.contents)
|
||||
// const appsToDelete = diff(previousAppIds, currentAppIds)
|
||||
// appsToDelete.map(appId => this.delete(appId))
|
||||
// }
|
||||
|
||||
// server state change
|
||||
markAppsUnreachable (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNREACHABLE })
|
||||
}
|
||||
// // server state change
|
||||
// markAppsUnreachable (): void {
|
||||
// this.updateAllApps({ status: AppStatus.UNREACHABLE })
|
||||
// }
|
||||
|
||||
markAppsUnknown (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNKNOWN })
|
||||
}
|
||||
// markAppsUnknown (): void {
|
||||
// this.updateAllApps({ status: AppStatus.UNKNOWN })
|
||||
// }
|
||||
|
||||
private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
|
||||
this.ids.map(id => {
|
||||
this.update(Object.assign(uniformUpdate, { id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
// private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
|
||||
// this.ids.map(id => {
|
||||
// this.update(Object.assign(uniformUpdate, { id }))
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
|
||||
return {
|
||||
instructions: null,
|
||||
lastBackup: null,
|
||||
configuredRequirements: null,
|
||||
hasFetchedFull: false,
|
||||
actions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export interface Rules {
|
||||
rule: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export enum AppStatus {
|
||||
// shared
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
UNREACHABLE = 'UNREACHABLE',
|
||||
INSTALLING = 'INSTALLING',
|
||||
NEEDS_CONFIG = 'NEEDS_CONFIG',
|
||||
RUNNING = 'RUNNING',
|
||||
STOPPED = 'STOPPED',
|
||||
CREATING_BACKUP = 'CREATING_BACKUP',
|
||||
RESTORING_BACKUP = 'RESTORING_BACKUP',
|
||||
CRASHED = 'CRASHED',
|
||||
REMOVING = 'REMOVING',
|
||||
DEAD = 'DEAD',
|
||||
BROKEN_DEPENDENCIES = 'BROKEN_DEPENDENCIES',
|
||||
STOPPING = 'STOPPING',
|
||||
RESTARTING = 'RESTARTING',
|
||||
}
|
||||
// function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
|
||||
// return {
|
||||
// instructions: null,
|
||||
// lastBackup: null,
|
||||
// configuredRequirements: null,
|
||||
// hasFetchedFull: false,
|
||||
// actions: [],
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { AppStatus } from './app-model'
|
||||
|
||||
/** APPS **/
|
||||
|
||||
export interface BaseApp {
|
||||
id: string
|
||||
title: string
|
||||
status: AppStatus | null
|
||||
versionInstalled: string | null
|
||||
iconURL: string
|
||||
}
|
||||
|
||||
// available
|
||||
export interface AppAvailablePreview extends BaseApp {
|
||||
versionLatest: string
|
||||
descriptionShort: string
|
||||
latestVersionTimestamp: Date //used for sorting AAL
|
||||
}
|
||||
|
||||
export type AppAvailableFull =
|
||||
AppAvailablePreview & {
|
||||
descriptionLong: string
|
||||
versions: string[]
|
||||
licenseName?: string // @TODO required for 0.3.0
|
||||
licenseLink?: string // @TODO required for 0.3.0
|
||||
} &
|
||||
AppAvailableVersionSpecificInfo
|
||||
|
||||
|
||||
export interface AppAvailableVersionSpecificInfo {
|
||||
releaseNotes: string
|
||||
serviceRequirements: AppDependency[]
|
||||
versionViewing: string
|
||||
installAlert?: string
|
||||
}
|
||||
// installed
|
||||
|
||||
export interface AppInstalledPreview extends BaseApp {
|
||||
lanAddress?: string
|
||||
torAddress: string
|
||||
versionInstalled: string
|
||||
lanUi: boolean
|
||||
torUi: boolean
|
||||
// FE state only
|
||||
hasUI: boolean
|
||||
launchable: boolean
|
||||
}
|
||||
|
||||
export interface AppInstalledFull extends AppInstalledPreview {
|
||||
licenseName?: string // @TODO required for 0.3.0
|
||||
licenseLink?: string // @TODO required for 0.3.0
|
||||
instructions: string | null
|
||||
lastBackup: string | null
|
||||
configuredRequirements: AppDependency[] | null // null if not yet configured
|
||||
startAlert?: string
|
||||
uninstallAlert?: string
|
||||
restoreAlert?: string
|
||||
actions: Actions
|
||||
// FE state only
|
||||
hasFetchedFull: boolean
|
||||
}
|
||||
|
||||
export type Actions = ServiceAction[]
|
||||
export interface ServiceAction {
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
warning?: string
|
||||
allowedStatuses: AppStatus[]
|
||||
}
|
||||
export interface AppDependency extends InstalledAppDependency {
|
||||
// explanation of why it *is* optional. null represents it is required.
|
||||
optional: string | null
|
||||
// whether it comes as defualt in the config. This will not be present on an installed app, as we only care
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface InstalledAppDependency extends Omit<BaseApp, 'versionInstalled' | 'status'> {
|
||||
// semver specification
|
||||
versionSpec: string
|
||||
|
||||
// an optional description of how this dependency is utlitized by the host app
|
||||
description: string | null
|
||||
|
||||
// how the requirement is failed, null means satisfied. If the dependency is optional, this should still be set as though it were required.
|
||||
// This way I can say "it's optional, but also you would need to upgrade it to versionSpec" or "it's optional, but you don't even have it"
|
||||
// Said another way, if violaion === null, then this thing as a requirement is straight up satisfied.
|
||||
violation: DependencyViolation | null
|
||||
}
|
||||
|
||||
export enum DependencyViolationSeverity {
|
||||
NONE = 0,
|
||||
OPTIONAL = 1,
|
||||
RECOMMENDED = 2,
|
||||
REQUIRED = 3,
|
||||
}
|
||||
export function getViolationSeverity (r: AppDependency): DependencyViolationSeverity {
|
||||
if (!r.optional && r.violation) return DependencyViolationSeverity.REQUIRED
|
||||
if (r.optional && r.default && r.violation) return DependencyViolationSeverity.RECOMMENDED
|
||||
if (isOptional(r) && r.violation) return DependencyViolationSeverity.OPTIONAL
|
||||
return DependencyViolationSeverity.NONE
|
||||
}
|
||||
|
||||
// optional not recommended
|
||||
export function isOptional (r: AppDependency): boolean {
|
||||
return r.optional && !r.default
|
||||
}
|
||||
|
||||
export function isRecommended (r: AppDependency): boolean {
|
||||
return r.optional && r.default
|
||||
}
|
||||
|
||||
export function isMissing (r: AppDependency) {
|
||||
return r.violation && r.violation.name === 'missing'
|
||||
}
|
||||
|
||||
export function isMisconfigured (r: AppDependency) {
|
||||
return r.violation && r.violation.name === 'incompatible-config'
|
||||
}
|
||||
|
||||
export function isNotRunning (r: AppDependency) {
|
||||
return r.violation && r.violation.name === 'incompatible-status'
|
||||
}
|
||||
|
||||
export function isVersionMismatch (r: AppDependency) {
|
||||
return r.violation && r.violation.name === 'incompatible-version'
|
||||
}
|
||||
|
||||
export function isInstalling (r: AppDependency) {
|
||||
return r.violation && r.violation.name === 'incompatible-status' && r.violation.status === AppStatus.INSTALLING
|
||||
}
|
||||
|
||||
|
||||
// both or none
|
||||
export function getInstalledViolationSeverity (r: InstalledAppDependency): DependencyViolationSeverity {
|
||||
if (r.violation) return DependencyViolationSeverity.REQUIRED
|
||||
return DependencyViolationSeverity.NONE
|
||||
}
|
||||
// e.g. of I try to uninstall a thing, and some installed apps break, those apps will be returned as instances of this type.
|
||||
export type DependentBreakage = Omit<BaseApp, 'versionInstalled' | 'status'>
|
||||
|
||||
export type DependencyViolation =
|
||||
{ name: 'missing' } |
|
||||
{ name: 'incompatible-version' } |
|
||||
{ name: 'incompatible-config'; ruleViolations: string[]; } |
|
||||
{ name: 'incompatible-status'; status: AppStatus; }
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AppModel } from './app-model'
|
||||
import { AppInstalledFull, AppInstalledPreview } from './app-types'
|
||||
import { ApiService } from '../services/api/api.service'
|
||||
import { PropertySubject, PropertySubjectId } from '../util/property-subject.util'
|
||||
import { S9Server, ServerModel } from './server-model'
|
||||
import { Observable, of, from } from 'rxjs'
|
||||
import { map, concatMap } from 'rxjs/operators'
|
||||
import { fromSync$ } from '../util/rxjs.util'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ModelPreload {
|
||||
constructor (
|
||||
private readonly appModel: AppModel,
|
||||
private readonly api: ApiService,
|
||||
private readonly serverModel: ServerModel,
|
||||
) { }
|
||||
|
||||
apps (): Observable<PropertySubjectId<AppInstalledFull | AppInstalledPreview>[]> {
|
||||
return fromSync$(() => this.appModel.getContents()).pipe(concatMap(apps => {
|
||||
const now = new Date()
|
||||
if (this.appModel.hasLoaded) {
|
||||
return of(apps)
|
||||
} else {
|
||||
return from(this.api.getInstalledApps()).pipe(
|
||||
map(appsRes => {
|
||||
this.appModel.upsertApps(appsRes, now)
|
||||
return this.appModel.getContents()
|
||||
}),
|
||||
)
|
||||
}}),
|
||||
)
|
||||
}
|
||||
|
||||
appFull (appId: string): Observable<PropertySubject<AppInstalledFull> > {
|
||||
return fromSync$(() => this.appModel.watch(appId)).pipe(
|
||||
concatMap(app => {
|
||||
// if we haven't fetched full, don't return till we do
|
||||
// if we have fetched full, go ahead and return now, but fetch full again in the background
|
||||
if (!app.hasFetchedFull.getValue()) {
|
||||
return from(this.loadInstalledApp(appId))
|
||||
} else {
|
||||
this.loadInstalledApp(appId)
|
||||
return of(app)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
loadInstalledApp (appId: string): Promise<PropertySubject<AppInstalledFull>> {
|
||||
const now = new Date()
|
||||
return this.api.getInstalledApp(appId).then(res => {
|
||||
this.appModel.update({ id: appId, ...res, hasFetchedFull: true }, now)
|
||||
return this.appModel.watch(appId)
|
||||
})
|
||||
}
|
||||
|
||||
server (): Observable<PropertySubject<S9Server>> {
|
||||
return fromSync$(() => this.serverModel.watch()).pipe(concatMap(sw => {
|
||||
if (sw.versionInstalled.getValue()) {
|
||||
return of(sw)
|
||||
} else {
|
||||
console.warn(`server not present, preloading`)
|
||||
return from(this.api.getServer()).pipe(
|
||||
map(res => {
|
||||
this.serverModel.update(res)
|
||||
return this.serverModel.watch()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
364
ui/src/app/models/patch-db/data-model.ts
Normal file
364
ui/src/app/models/patch-db/data-model.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
|
||||
export interface DataModel {
|
||||
'server-info': ServerInfo
|
||||
'package-data': { [id: string]: PackageDataEntry }
|
||||
ui: {
|
||||
'server-name': string
|
||||
'welcome-ack': string
|
||||
'auto-check-updates': boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string
|
||||
version: string
|
||||
'lan-address': URL
|
||||
'tor-address': URL
|
||||
status: ServerStatus
|
||||
registry: URL
|
||||
wifi: WiFiInfo
|
||||
'unread-notification-count': number
|
||||
specs: {
|
||||
CPU: string
|
||||
Disk: string
|
||||
Memory: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum ServerStatus {
|
||||
Running = 'running',
|
||||
Updating = 'updating',
|
||||
BackingUp = 'backing-up',
|
||||
}
|
||||
|
||||
export interface WiFiInfo {
|
||||
ssids: string[]
|
||||
selected: string | null
|
||||
connected: string | null
|
||||
}
|
||||
|
||||
export interface PackageDataEntry {
|
||||
state: PackageState
|
||||
'static-files': {
|
||||
license: URL
|
||||
instructions: URL
|
||||
icon: URL
|
||||
}
|
||||
'temp-manifest'?: Manifest // exists when: installing, updating, removing
|
||||
installed?: InstalledPackageDataEntry, // exists when: installed, updating
|
||||
'install-progress'?: InstallProgress, // exists when: installing, updating
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
size: number | null
|
||||
downloaded: number
|
||||
'download-complete': boolean
|
||||
validated: number
|
||||
'validation-complete': boolean
|
||||
read: number
|
||||
'read-complete': boolean
|
||||
}
|
||||
|
||||
export interface InstalledPackageDataEntry {
|
||||
manifest: Manifest
|
||||
status: Status
|
||||
'interface-info': InterfaceInfo
|
||||
'system-pointers': any[]
|
||||
'current-dependents': { [id: string]: CurrentDependencyInfo }
|
||||
'current-dependencies': { [id: string]: CurrentDependencyInfo }
|
||||
}
|
||||
|
||||
export interface CurrentDependencyInfo {
|
||||
pointers: any[]
|
||||
'health-checks': string[] // array of health check IDs
|
||||
}
|
||||
|
||||
export enum PackageState {
|
||||
Installing = 'installing',
|
||||
Installed = 'installed',
|
||||
Updating = 'updating',
|
||||
Removing = 'removing',
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
description: {
|
||||
short: string
|
||||
long: string
|
||||
}
|
||||
'release-notes': string
|
||||
license: string // name
|
||||
'wrapper-repo': URL
|
||||
'upstream-repo': URL
|
||||
'support-site': URL
|
||||
'marketing-site': URL
|
||||
'donation-url': URL | null
|
||||
alerts: {
|
||||
install: string | null
|
||||
uninstall: string | null
|
||||
restore: string | null
|
||||
start: string | null
|
||||
stop: string | null
|
||||
}
|
||||
main: ActionImpl
|
||||
'health-check': ActionImpl
|
||||
config: ConfigActions | null
|
||||
volumes: { [id: string]: Volume }
|
||||
'min-os-version': string
|
||||
interfaces: { [id: string]: InterfaceDef }
|
||||
backup: BackupActions
|
||||
migrations: Migrations
|
||||
actions: { [id: string]: Action }
|
||||
permissions: any // @TODO
|
||||
dependencies: DependencyInfo
|
||||
}
|
||||
|
||||
export interface ActionImpl {
|
||||
type: 'docker'
|
||||
image: string
|
||||
system: boolean
|
||||
entrypoint: string
|
||||
args: string[]
|
||||
mounts: { [id: string]: string }
|
||||
'io-format': DockerIoFormat | null
|
||||
inject: boolean
|
||||
'shm-size': string
|
||||
}
|
||||
|
||||
export enum DockerIoFormat {
|
||||
Json = 'json',
|
||||
Yaml = 'yaml',
|
||||
Cbor = 'cbor',
|
||||
Toml = 'toml',
|
||||
}
|
||||
|
||||
export interface ConfigActions {
|
||||
get: ActionImpl
|
||||
set: ActionImpl
|
||||
}
|
||||
|
||||
export type Volume = VolumeData
|
||||
|
||||
export interface VolumeData {
|
||||
type: VolumeType.Data
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export interface VolumePointer {
|
||||
type: VolumeType.Pointer
|
||||
'package-id': string
|
||||
'volume-id': string
|
||||
path: string
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export interface VolumeCertificate {
|
||||
type: VolumeType.Certificate
|
||||
'interface-id': string
|
||||
}
|
||||
|
||||
export interface VolumeHiddenService {
|
||||
type: VolumeType.HiddenService
|
||||
'interface-id': string
|
||||
}
|
||||
|
||||
export interface VolumeBackup {
|
||||
type: VolumeType.Backup
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export enum VolumeType {
|
||||
Data = 'data',
|
||||
Pointer = 'pointer',
|
||||
Certificate = 'certificate',
|
||||
HiddenService = 'hidden-service',
|
||||
Backup = 'backup',
|
||||
}
|
||||
|
||||
export interface InterfaceDef {
|
||||
name: string
|
||||
description: string
|
||||
ui: boolean
|
||||
'tor-config': TorConfig | null
|
||||
'lan-config': LanConfig | null
|
||||
protocols: string[]
|
||||
}
|
||||
|
||||
export interface TorConfig {
|
||||
'hidden-service-version': string
|
||||
'port-mapping': { [port: number]: number }
|
||||
}
|
||||
|
||||
export type LanConfig = {
|
||||
[port: number]: { ssl: boolean, mapping: number }
|
||||
}
|
||||
|
||||
export interface BackupActions {
|
||||
create: ActionImpl
|
||||
restore: ActionImpl
|
||||
}
|
||||
|
||||
export interface Migrations {
|
||||
from: { [versionRange: string]: ActionImpl }
|
||||
to: { [versionRange: string]: ActionImpl }
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
name: string
|
||||
description: string
|
||||
warning: string | null
|
||||
implementation: ActionImpl
|
||||
'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[]
|
||||
'input-spec': ConfigSpec
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
configured: boolean
|
||||
main: MainStatus
|
||||
'dependency-errors': { [id: string]: DependencyError }
|
||||
}
|
||||
|
||||
export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp | MainStatusRestoring
|
||||
|
||||
export interface MainStatusStopped {
|
||||
status: PackageMainStatus.Stopped
|
||||
}
|
||||
|
||||
export interface MainStatusStopping {
|
||||
status: PackageMainStatus.Stopping
|
||||
}
|
||||
|
||||
export interface MainStatusRunning {
|
||||
status: PackageMainStatus.Running
|
||||
started: string // UTC date string
|
||||
health: { [id: string]: HealthCheckResult }
|
||||
}
|
||||
|
||||
export interface MainStatusBackingUp {
|
||||
status: PackageMainStatus.BackingUp
|
||||
started: string | null // UTC date string
|
||||
}
|
||||
|
||||
export interface MainStatusRestoring {
|
||||
status: PackageMainStatus.Restoring
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export enum PackageMainStatus {
|
||||
Running = 'running',
|
||||
Stopping = 'stopping',
|
||||
Stopped = 'stopped',
|
||||
BackingUp = 'backing-up',
|
||||
Restoring = 'restoring',
|
||||
}
|
||||
|
||||
export type HealthCheckResult = HealthCheckResultWarmingUp | HealthCheckResultDisabled | HealthCheckResultSuccess | HealthCheckResultFailure
|
||||
|
||||
export interface HealthCheckResultWarmingUp {
|
||||
time: string // UTC date string
|
||||
result: 'warming-up'
|
||||
}
|
||||
|
||||
export interface HealthCheckResultDisabled {
|
||||
time: string // UTC date string
|
||||
result: 'disabled'
|
||||
}
|
||||
|
||||
export interface HealthCheckResultSuccess {
|
||||
time: string // UTC date string
|
||||
result: 'success'
|
||||
}
|
||||
|
||||
export interface HealthCheckResultFailure {
|
||||
time: string // UTC date string
|
||||
result: 'failure'
|
||||
error: string
|
||||
}
|
||||
|
||||
export type DependencyError = DependencyErrorNotInstalled | DependencyErrorNotRunning | DependencyErrorIncorrectVersion | DependencyErrorConfigUnsatisfied | DependencyErrorHealthCheckFailed | DependencyErrorInterfaceHealthChecksFailed
|
||||
|
||||
export enum DependencyErrorType {
|
||||
NotInstalled = 'not-installed',
|
||||
NotRunning = 'not-running',
|
||||
IncorrectVersion = 'incorrect-version',
|
||||
ConfigUnsatisfied = 'config-unsatisfied',
|
||||
HealthCheckFailed = 'health-check-failed',
|
||||
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotInstalled {
|
||||
type: DependencyErrorType.NotInstalled
|
||||
title: string
|
||||
icon: URL
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotRunning {
|
||||
type: DependencyErrorType.NotRunning
|
||||
}
|
||||
|
||||
export interface DependencyErrorIncorrectVersion {
|
||||
type: DependencyErrorType.IncorrectVersion
|
||||
expected: string // version range
|
||||
received: string // version
|
||||
}
|
||||
|
||||
export interface DependencyErrorConfigUnsatisfied {
|
||||
type: DependencyErrorType.ConfigUnsatisfied
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface DependencyErrorHealthCheckFailed {
|
||||
type: DependencyErrorType.HealthCheckFailed
|
||||
check: HealthCheckResult
|
||||
}
|
||||
|
||||
export interface DependencyErrorInterfaceHealthChecksFailed {
|
||||
type: DependencyErrorType.InterfaceHealthChecksFailed
|
||||
failures: { [id: string]: HealthCheckResult }
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
[id: string]: DependencyEntry
|
||||
}
|
||||
|
||||
export interface DependencyEntry {
|
||||
version: string
|
||||
optional: string | null
|
||||
recommended: boolean
|
||||
description: string | null
|
||||
config: ConfigRuleEntryWithSuggestions[]
|
||||
interfaces: any[] // @TODO placeholder
|
||||
}
|
||||
|
||||
export interface ConfigRuleEntryWithSuggestions {
|
||||
rule: string
|
||||
description: string
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
condition: string | null
|
||||
set?: {
|
||||
var: string
|
||||
to?: string
|
||||
'to-value'?: any
|
||||
'to-entropy'?: { charset: string, len: number }
|
||||
}
|
||||
delete?: string
|
||||
push?: {
|
||||
to: string
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface InterfaceInfo {
|
||||
ip: string
|
||||
addresses: {
|
||||
[id: string]: { 'tor-address': string, 'lan-address': string }
|
||||
}
|
||||
}
|
||||
|
||||
export type URL = string
|
||||
29
ui/src/app/models/patch-db/local-storage-bootstrap.ts
Normal file
29
ui/src/app/models/patch-db/local-storage-bootstrap.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Bootstrapper, DBCache } from 'patch-db-client'
|
||||
import { DataModel } from './data-model'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Storage } from '@ionic/storage'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
|
||||
static CONTENT_KEY = 'patch-db-cache'
|
||||
|
||||
constructor (
|
||||
private readonly storage: Storage,
|
||||
) { }
|
||||
|
||||
async init (): Promise<DBCache<DataModel>> {
|
||||
const cache = await this.storage.get(LocalStorageBootstrap.CONTENT_KEY)
|
||||
if (!cache) return { sequence: 0, data: { } as DataModel }
|
||||
return cache
|
||||
}
|
||||
|
||||
async update (cache: DBCache<DataModel>): Promise<void> {
|
||||
return this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
|
||||
}
|
||||
|
||||
async clear (): Promise<void> {
|
||||
return this.storage.remove(LocalStorageBootstrap.CONTENT_KEY)
|
||||
}
|
||||
}
|
||||
24
ui/src/app/models/patch-db/patch-db-model.factory.ts
Normal file
24
ui/src/app/models/patch-db/patch-db-model.factory.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { PollSource, Source, WebsocketSource } from 'patch-db-client'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DataModel } from './data-model'
|
||||
import { LocalStorageBootstrap } from './local-storage-bootstrap'
|
||||
import { PatchDbModel } from './patch-db-model'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
export function PatchDbModelFactory (
|
||||
config: ConfigService,
|
||||
bootstrapper: LocalStorageBootstrap,
|
||||
http: ApiService,
|
||||
): PatchDbModel {
|
||||
|
||||
const { patchDb: { usePollOverride, poll, websocket, timeoutForMissingRevision }, isConsulate } = config
|
||||
|
||||
let source: Source<DataModel>
|
||||
if (isConsulate || usePollOverride) {
|
||||
source = new PollSource({ ...poll }, http)
|
||||
} else {
|
||||
source = new WebsocketSource({ ...websocket })
|
||||
}
|
||||
|
||||
return new PatchDbModel({ sources: [source, http], bootstrapper, http, timeoutForMissingRevision })
|
||||
}
|
||||
52
ui/src/app/models/patch-db/patch-db-model.ts
Normal file
52
ui/src/app/models/patch-db/patch-db-model.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core'
|
||||
import { PatchDB, PatchDbConfig, Store } from 'patch-db-client'
|
||||
import { Observable, of, Subscription } from 'rxjs'
|
||||
import { catchError, finalize } from 'rxjs/operators'
|
||||
import { DataModel } from './data-model'
|
||||
|
||||
export const PATCH_CONFIG = new InjectionToken<PatchDbConfig<DataModel>>('app.config')
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchDbModel {
|
||||
private patchDb: PatchDB<DataModel>
|
||||
private syncSub: Subscription
|
||||
initialized = false
|
||||
|
||||
constructor (
|
||||
@Inject(PATCH_CONFIG) private readonly conf: PatchDbConfig<DataModel>,
|
||||
) { }
|
||||
|
||||
async init (): Promise<void> {
|
||||
if (this.patchDb) return console.warn('Cannot re-init patchDbModel')
|
||||
this.patchDb = await PatchDB.init<DataModel>(this.conf)
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
start (): void {
|
||||
if (this.syncSub) this.stop()
|
||||
this.syncSub = this.patchDb.sync$().subscribe({
|
||||
error: e => console.error('Critical, patch-db-sync sub error', e),
|
||||
complete: () => console.error('Critical, patch-db-sync sub complete'),
|
||||
})
|
||||
}
|
||||
|
||||
stop (): void {
|
||||
if (this.syncSub) {
|
||||
this.syncSub.unsubscribe()
|
||||
this.syncSub = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch$: Store<DataModel>['watch$'] = (...args: (string | number)[]): Observable<DataModel> => {
|
||||
// console.log('WATCHING')
|
||||
return this.patchDb.store.watch$(...(args as [])).pipe(
|
||||
catchError(e => {
|
||||
console.error(e)
|
||||
return of(e.message)
|
||||
}),
|
||||
// finalize(() => console.log('unSUBSCRIBing')),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Subject, BehaviorSubject } from 'rxjs'
|
||||
import { PropertySubject, peekProperties, initPropertySubject } from '../util/property-subject.util'
|
||||
import { AppModel } from './app-model'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { Storage } from '@ionic/storage'
|
||||
import { throttleTime, delay } from 'rxjs/operators'
|
||||
import { StorageKeys } from './storage-keys'
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ServerModel {
|
||||
lastUpdateTimestamp: Date
|
||||
$delta$ = new Subject<void>()
|
||||
private embassy: PropertySubject<S9Server>
|
||||
|
||||
constructor (
|
||||
private readonly storage: Storage,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.embassy = this.defaultEmbassy()
|
||||
this.$delta$.pipe(
|
||||
throttleTime(500), delay(500),
|
||||
).subscribe(() => {
|
||||
this.commitCache()
|
||||
})
|
||||
}
|
||||
|
||||
// client fxns
|
||||
watch (): PropertySubject<S9Server> {
|
||||
return this.embassy
|
||||
}
|
||||
|
||||
peek (): S9Server {
|
||||
return peekProperties(this.embassy)
|
||||
}
|
||||
|
||||
update (update: Partial<S9Server>, timestamp: Date = new Date()): void {
|
||||
if (this.lastUpdateTimestamp > timestamp) return
|
||||
|
||||
if (update.versionInstalled && (update.versionInstalled !== this.config.version) && this.embassy.status.getValue() === ServerStatus.RUNNING) {
|
||||
console.log('update detected, force reload page')
|
||||
this.clear()
|
||||
this.nukeCache().then(
|
||||
() => location.replace('?upd=' + new Date()),
|
||||
)
|
||||
}
|
||||
|
||||
Object.entries(update).forEach(
|
||||
([key, value]) => {
|
||||
if (!this.embassy[key]) {
|
||||
console.warn('Received an unexpected key: ', key)
|
||||
this.embassy[key] = new BehaviorSubject(value)
|
||||
} else if (JSON.stringify(this.embassy[key].getValue()) !== JSON.stringify(value)) {
|
||||
this.embassy[key].next(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
this.$delta$.next()
|
||||
this.lastUpdateTimestamp = timestamp
|
||||
}
|
||||
|
||||
// cache mgmt
|
||||
clear () {
|
||||
this.update(peekProperties(this.defaultEmbassy()))
|
||||
}
|
||||
|
||||
private commitCache (): Promise<void> {
|
||||
return this.storage.set(StorageKeys.SERVER_CACHE_KEY, peekProperties(this.embassy))
|
||||
}
|
||||
|
||||
private nukeCache (): Promise<void> {
|
||||
return this.storage.remove(StorageKeys.SERVER_CACHE_KEY)
|
||||
}
|
||||
|
||||
async restoreCache (): Promise<void> {
|
||||
const emb = await this.storage.get(StorageKeys.SERVER_CACHE_KEY)
|
||||
if (emb && emb.versionInstalled === this.config.version) this.update(emb)
|
||||
}
|
||||
|
||||
// server state change
|
||||
markUnreachable (): void {
|
||||
this.update({ status: ServerStatus.UNREACHABLE })
|
||||
this.appModel.markAppsUnreachable()
|
||||
}
|
||||
|
||||
markUnknown (): void {
|
||||
this.update({ status: ServerStatus.UNKNOWN })
|
||||
this.appModel.markAppsUnknown()
|
||||
}
|
||||
|
||||
defaultEmbassy (): PropertySubject<S9Server> {
|
||||
return initPropertySubject({
|
||||
serverId: undefined,
|
||||
name: undefined,
|
||||
origin: this.config.origin,
|
||||
versionInstalled: undefined,
|
||||
versionLatest: undefined,
|
||||
status: ServerStatus.UNKNOWN,
|
||||
badge: 0,
|
||||
alternativeRegistryUrl: undefined,
|
||||
specs: { },
|
||||
wifi: { ssids: [], current: undefined },
|
||||
ssh: [],
|
||||
notifications: [],
|
||||
welcomeAck: true,
|
||||
autoCheckUpdates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
export interface S9Server {
|
||||
serverId: string
|
||||
name: string
|
||||
origin: string
|
||||
versionInstalled: string
|
||||
versionLatest: string | undefined // not on the api as of 0.2.8
|
||||
status: ServerStatus
|
||||
badge: number
|
||||
alternativeRegistryUrl: string | null
|
||||
specs: ServerSpecs
|
||||
wifi: { ssids: string[], current: string }
|
||||
ssh: SSHFingerprint[]
|
||||
notifications: S9Notification[]
|
||||
welcomeAck: boolean
|
||||
autoCheckUpdates: boolean
|
||||
}
|
||||
|
||||
export interface S9Notification {
|
||||
id: string
|
||||
appId: string
|
||||
createdAt: string
|
||||
code: string
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ServerSpecs {
|
||||
[key: string]: string | number
|
||||
}
|
||||
|
||||
export interface ServerMetrics {
|
||||
[key: string]: {
|
||||
[key: string]: {
|
||||
value: string | number | null
|
||||
unit?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SSHFingerprint {
|
||||
alg: string
|
||||
hash: string
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export interface DiskInfo {
|
||||
logicalname: string,
|
||||
size: string,
|
||||
description: string | null,
|
||||
partitions: DiskPartition[]
|
||||
}
|
||||
|
||||
export interface DiskPartition {
|
||||
logicalname: string,
|
||||
isMounted: boolean, // We do not allow backups to mounted partitions
|
||||
size: string | null,
|
||||
label: string | null,
|
||||
}
|
||||
|
||||
export enum ServerStatus {
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
UNREACHABLE = 'UNREACHABLE',
|
||||
UPDATING = 'UPDATING',
|
||||
NEEDS_CONFIG = 'NEEDS_CONFIG',
|
||||
RUNNING = 'RUNNING',
|
||||
}
|
||||
Reference in New Issue
Block a user