mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
161
ui/src/app/models/app-model.ts
Normal file
161
ui/src/app/models/app-model.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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'
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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(
|
||||
pairwise(),
|
||||
filter( ([old, _]) => old === AppStatus.INSTALLING ),
|
||||
take(1),
|
||||
mapTo(appId),
|
||||
)
|
||||
}
|
||||
|
||||
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 = { }
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// server state change
|
||||
markAppsUnreachable (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNREACHABLE })
|
||||
}
|
||||
|
||||
markAppsUnknown (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNKNOWN })
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
122
ui/src/app/models/app-types.ts
Normal file
122
ui/src/app/models/app-types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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
|
||||
}
|
||||
|
||||
export type AppAvailableFull =
|
||||
AppAvailablePreview &
|
||||
{ descriptionLong: string
|
||||
versions: string[]
|
||||
} &
|
||||
AppAvailableVersionSpecificInfo
|
||||
|
||||
|
||||
export interface AppAvailableVersionSpecificInfo {
|
||||
releaseNotes: string
|
||||
serviceRequirements: AppDependency[]
|
||||
versionViewing: string
|
||||
}
|
||||
// installed
|
||||
|
||||
export interface AppInstalledPreview extends BaseApp {
|
||||
torAddress: string
|
||||
versionInstalled: string
|
||||
}
|
||||
|
||||
export interface AppInstalledFull extends AppInstalledPreview {
|
||||
instructions: string | null
|
||||
lastBackup: string | null
|
||||
configuredRequirements: AppDependency[] | null // null if not yet configured
|
||||
hasFetchedFull: boolean
|
||||
}
|
||||
// dependencies
|
||||
|
||||
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; }
|
||||
73
ui/src/app/models/model-preload.ts
Normal file
73
ui/src/app/models/model-preload.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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>> {
|
||||
return this.api.getInstalledApp(appId).then(res => {
|
||||
this.appModel.update({ id: appId, ...res, hasFetchedFull: true })
|
||||
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()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
175
ui/src/app/models/server-model.ts
Normal file
175
ui/src/app/models/server-model.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface S9Server {
|
||||
serverId: string
|
||||
name: string
|
||||
origin: string
|
||||
versionInstalled: string
|
||||
versionLatest: string | undefined
|
||||
status: ServerStatus
|
||||
badge: number
|
||||
alternativeRegistryUrl: string | null
|
||||
specs: ServerSpecs
|
||||
wifi: { ssids: string[], current: string }
|
||||
ssh: SSHFingerprint[]
|
||||
notifications: S9Notification[]
|
||||
}
|
||||
|
||||
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, // Do not let them back up to this if true
|
||||
size: string | null,
|
||||
label: string | null,
|
||||
}
|
||||
|
||||
export enum ServerStatus {
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
UNREACHABLE = 'UNREACHABLE',
|
||||
UPDATING = 'UPDATING',
|
||||
NEEDS_CONFIG = 'NEEDS_CONFIG',
|
||||
RUNNING = 'RUNNING',
|
||||
}
|
||||
6
ui/src/app/models/storage-keys.ts
Normal file
6
ui/src/app/models/storage-keys.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class StorageKeys {
|
||||
static APPS_CACHE_KEY = 'apps'
|
||||
static SERVER_CACHE_KEY = 'embassy'
|
||||
static LOGGED_IN_KEY = 'loggedInKey'
|
||||
static VIEWED_INSTRUCTIONS_KEY = 'viewedInstructions'
|
||||
}
|
||||
Reference in New Issue
Block a user