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:
Aaron Greenspan
2021-02-16 13:45:09 -07:00
committed by Aiden McClelland
parent fd685ae32c
commit 594d93eb3b
238 changed files with 15137 additions and 21331 deletions

View File

@@ -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: [],
// }
// }

View File

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

View File

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

View 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

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

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

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

View File

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