0.2.5 initial commit

Makefile incomplete
This commit is contained in:
Aiden McClelland
2020-11-23 13:44:28 -07:00
commit 95d3845906
503 changed files with 53448 additions and 0 deletions

View 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',
}

View 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; }

View 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()
}))
}
}))
}
}

View 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',
}

View 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'
}