Feat/combine uis (#2633)

* wip

* restructure backend for new ui structure

* new patchdb bootstrap, single websocket api, local storage migration, more

* update db websocket

* init apis

* update patch-db

* setup progress

* feat: implement state service, alert and routing

Signed-off-by: waterplea <alexander@inkin.ru>

* update setup wizard for new types

* feat: add init page

Signed-off-by: waterplea <alexander@inkin.ru>

* chore: refactor message, patch-db source stream and connection service

Signed-off-by: waterplea <alexander@inkin.ru>

* fix method not found on state

* fix backend bugs

* fix compat assets

* address comments

* remove unneeded styling

* cleaner progress

* bugfixes

* fix init logs

* fix progress reporting

* fix navigation by getting state after init

* remove patch dependency from live api

* fix caching

* re-add patchDB to live api

* fix metrics values

* send close frame

* add bootId and fix polling

---------

Signed-off-by: waterplea <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: waterplea <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2024-06-19 13:51:44 -06:00
committed by GitHub
parent e92d4ff147
commit da3720c7a9
147 changed files with 3939 additions and 2637 deletions

View File

@@ -16,7 +16,7 @@ export module Mock {
restarting: false,
shuttingDown: false,
}
export const MarketplaceEos: RR.GetMarketplaceEosRes = {
export const MarketplaceEos: RR.CheckOSUpdateRes = {
version: '0.3.5.2',
headline: 'Our biggest release ever.',
releaseNotes: {
@@ -493,30 +493,23 @@ export module Mock {
{
timestamp: '2022-07-28T03:52:54.808769Z',
message: '****** START *****',
bootId: 'hsjnfdklasndhjasvbjamsksajbndjn',
},
{
timestamp: '2019-12-26T14:21:30.872Z',
message:
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.startos/api/graphql \u001b[0;36;49m1.169406ms\u001b',
bootId: 'hsjnfdklasndhjasvbjamsksajbndjn',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
message: '****** FINISH *****',
},
]
export const PackageLogs: Log[] = [
{
timestamp: '2022-07-28T03:52:54.808769Z',
message: '****** START *****',
bootId: 'gvbwfiuasokdasjndasnjdmfvbahjdmdkfm',
},
{
timestamp: '2019-12-26T14:21:30.872Z',
message: 'PackageLogs PackageLogs PackageLogs PackageLogs PackageLogs',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
message: '****** FINISH *****',
timestamp: '2019-12-26T15:22:30.872Z',
message: '****** AGAIN *****',
bootId: 'gvbwfiuasokdasjndasnjdmfvbahjdmdkfm',
},
]

View File

@@ -1,17 +1,28 @@
import { Dump, Revision } from 'patch-db-client'
import { Dump } from 'patch-db-client'
import { MarketplacePkg, StoreInfo } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export module RR {
// websocket
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
// server state
export type ServerState = 'initializing' | 'error' | 'running'
// DB
export type GetRevisionsRes = Revision[] | Dump<DataModel>
export type GetDumpRes = Dump<DataModel>
export type SubscribePatchReq = {}
export type SubscribePatchRes = {
dump: Dump<DataModel>
guid: string
}
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
export type SetDBValueRes = null
@@ -33,10 +44,22 @@ export module RR {
} // auth.reset-password
export type ResetPasswordRes = null
// server
// diagnostic
export type EchoReq = { message: string; timeout?: number } // server.echo
export type EchoRes = string
export type DiagnosticErrorRes = {
code: number
message: string
data: { details: string }
}
// init
export type InitGetProgressRes = {
progress: T.FullProgress
guid: string
}
// server
export type GetSystemTimeReq = {} // server.time
export type GetSystemTimeRes = {
@@ -65,8 +88,8 @@ export module RR {
export type ShutdownServerReq = {} // server.shutdown
export type ShutdownServerRes = null
export type SystemRebuildReq = {} // server.rebuild
export type SystemRebuildRes = null
export type DiskRepairReq = {} // server.disk.repair
export type DiskRepairRes = null
export type ResetTorReq = {
wipeState: boolean
@@ -254,8 +277,8 @@ export module RR {
export type GetMarketplaceInfoReq = { serverId: string }
export type GetMarketplaceInfoRes = StoreInfo
export type GetMarketplaceEosReq = { serverId: string }
export type GetMarketplaceEosRes = MarketplaceEOS
export type CheckOSUpdateReq = { serverId: string }
export type CheckOSUpdateRes = OSUpdate
export type GetMarketplacePackagesReq = {
ids?: { id: string; version: string }[]
@@ -271,7 +294,7 @@ export module RR {
export type GetReleaseNotesRes = { [version: string]: string }
}
export interface MarketplaceEOS {
export interface OSUpdate {
version: string
headline: string
releaseNotes: { [version: string]: string }

View File

@@ -1,9 +1,5 @@
import { Observable } from 'rxjs'
import { Update } from 'patch-db-client'
import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log } from '@start9labs/shared'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService {
// http
@@ -14,8 +10,23 @@ export abstract class ApiService {
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<string>
// websocket
abstract openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T>
// server state
abstract getState(): Promise<RR.ServerState>
// db
abstract subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes>
abstract setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -35,16 +46,26 @@ export abstract class ApiService {
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes>
// diagnostic
abstract diagnosticGetError(): Promise<RR.DiagnosticErrorRes>
abstract diagnosticRestart(): Promise<void>
abstract diagnosticForgetDrive(): Promise<void>
abstract diagnosticRepairDisk(): Promise<void>
abstract diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
// init
abstract initGetProgress(): Promise<RR.InitGetProgressRes>
abstract initFollowLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
// server
abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes>
@@ -89,11 +110,7 @@ export abstract class ApiService {
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes>
abstract systemRebuild(
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes>
abstract repairDisk(params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
abstract repairDisk(params: RR.DiskRepairReq): Promise<RR.DiskRepairRes>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
@@ -105,7 +122,7 @@ export abstract class ApiService {
url: string,
): Promise<T>
abstract getEos(): Promise<RR.GetMarketplaceEosRes>
abstract checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes>
// notification

View File

@@ -3,7 +3,6 @@ import {
HttpOptions,
HttpService,
isRpcError,
Log,
Method,
RpcError,
RPCOptions,
@@ -12,13 +11,12 @@ import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { webSocket } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
import { PatchDB, pathFromArray, Update } from 'patch-db-client'
import { getServerInfo } from 'src/app/util/get-server-info'
import { PatchDB, pathFromArray } from 'patch-db-client'
@Injectable()
export class LiveApiService extends ApiService {
@@ -30,10 +28,11 @@ export class LiveApiService extends ApiService {
private readonly patch: PatchDB<DataModel>,
) {
super()
;(window as any).rpcClient = this
; (window as any).rpcClient = this
}
// for getting static files: ex icons, instructions, licenses
async getStatic(url: string): Promise<string> {
return this.httpRequest({
method: Method.GET,
@@ -43,6 +42,7 @@ export class LiveApiService extends ApiService {
}
// for sideloading packages
async uploadPackage(guid: string, body: Blob): Promise<string> {
return this.httpRequest({
method: Method.POST,
@@ -52,8 +52,36 @@ export class LiveApiService extends ApiService {
})
}
// websocket
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
const host = location.host
return webSocket({
url: `${protocol}://${host}/ws/rpc/${guid}`,
...config,
})
}
// state
async getState(): Promise<RR.ServerState> {
return this.rpcRequest({ method: 'state', params: {} })
}
// db
async subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes> {
return this.rpcRequest({ method: 'db.subscribe', params })
}
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -87,29 +115,57 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'auth.reset-password', params })
}
// diagnostic
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
return this.rpcRequest<RR.DiagnosticErrorRes>({
method: 'diagnostic.error',
params: {},
})
}
async diagnosticRestart(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.restart',
params: {},
})
}
async diagnosticForgetDrive(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.forget',
params: {},
})
}
async diagnosticRepairDisk(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.repair',
params: {},
})
}
async diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.rpcRequest<RR.GetServerLogsRes>({
method: 'diagnostic.logs',
params,
})
}
// init
async initGetProgress(): Promise<RR.InitGetProgressRes> {
return this.rpcRequest({ method: 'init.subscribe', params: {} })
}
async initFollowLogs(): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'init.logs.follow', params: {} })
}
// server
async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, urlOverride)
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
const config: WebSocketSubjectConfig<Update<DataModel>> = {
url: `/db`,
closeObserver: {
next: val => {
if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified()
},
},
}
return this.openWebsocket(config)
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return this.openWebsocket(config)
}
async getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes> {
@@ -175,12 +231,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.shutdown', params })
}
async systemRebuild(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
return this.rpcRequest({ method: 'server.rebuild', params })
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.rpcRequest({ method: 'disk.repair', params })
}
@@ -203,10 +253,7 @@ export class LiveApiService extends ApiService {
})
}
async getEos(): Promise<RR.GetMarketplaceEosRes> {
const { id } = await getServerInfo(this.patch)
const qp: RR.GetMarketplaceEosReq = { serverId: id }
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
return this.marketplaceProxy(
'/eos/v0/latest',
qp,
@@ -417,16 +464,6 @@ export class LiveApiService extends ApiService {
})
}
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
const host = location.host
config.url = `${protocol}://${host}/ws${config.url}`
return webSocket(config)
}
private async rpcRequest<T>(
options: RPCOptions,
urlOverride?: string,
@@ -445,9 +482,7 @@ export class LiveApiService extends ApiService {
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.patch.cache$.pipe(
filter(({ sequence }) => sequence >= Number(patchSequence)),
),
this.patch.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
)
return body.result

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@angular/core'
import { Log, pauseFor } from '@start9labs/shared'
import { Log, RPCErrorDetails, pauseFor } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import {
Operation,
PatchOp,
pathFromArray,
RemoveOperation,
Update,
Revision,
} from 'patch-db-client'
import {
DataModel,
InstallingState,
PackageDataEntry,
StateInfo,
@@ -20,22 +19,17 @@ import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
import {
EMPTY,
iif,
from,
interval,
map,
Observable,
shareReplay,
startWith,
Subject,
switchMap,
tap,
timer,
} from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { StoreInfo } from '@start9labs/marketplace'
import { T } from '@start9labs/start-sdk'
@@ -71,32 +65,17 @@ const PROGRESS: T.FullProgress = {
@Injectable()
export class MockApiService extends ApiService {
readonly mockWsSource$ = new Subject<Update<DataModel>>()
readonly mockWsSource$ = new Subject<Revision>()
private readonly revertTime = 1800
sequence = 0
constructor(
private readonly bootstrapper: LocalStorageBootstrap,
private readonly connectionService: ConnectionService,
private readonly auth: AuthService,
) {
constructor(private readonly auth: AuthService) {
super()
this.auth.isVerified$
.pipe(
tap(() => {
this.sequence = 0
}),
switchMap(verified =>
iif(
() => verified,
timer(2000).pipe(
tap(() => {
this.connectionService.websocketConnected$.next(true)
}),
),
EMPTY,
),
),
)
.subscribe()
}
@@ -111,8 +90,57 @@ export class MockApiService extends ApiService {
return 'success'
}
// websocket
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T> {
if (guid === 'db-guid') {
return this.mockWsSource$.pipe<any>(
shareReplay({ bufferSize: 1, refCount: true }),
)
} else if (guid === 'logs-guid') {
return interval(50).pipe<any>(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
if (index === 100) throw new Error('HAAHHA')
return Mock.ServerLogs[0]
}),
)
} else if (guid === 'init-progress-guid') {
return from(this.initProgress()).pipe(
startWith(PROGRESS),
) as Observable<T>
} else {
throw new Error('invalid guid type')
}
}
// server state
private stateIndex = 0
async getState(): Promise<RR.ServerState> {
await pauseFor(1000)
this.stateIndex++
return this.stateIndex === 1 ? 'initializing' : 'running'
}
// db
async subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes> {
await pauseFor(2000)
return {
dump: { id: 1, value: mockPatchData },
guid: 'db-guid',
}
}
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -136,11 +164,6 @@ export class MockApiService extends ApiService {
async login(params: RR.LoginReq): Promise<RR.loginRes> {
await pauseFor(2000)
setTimeout(() => {
this.mockWsSource$.next({ id: 1, value: mockPatchData })
}, 2000)
return null
}
@@ -166,34 +189,63 @@ export class MockApiService extends ApiService {
return null
}
// server
// diagnostic
async echo(params: RR.EchoReq, url?: string): Promise<RR.EchoRes> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
throw new Error()
async getError(): Promise<RPCErrorDetails> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async diagnosticRestart(): Promise<void> {
await pauseFor(1000)
}
async diagnosticForgetDrive(): Promise<void> {
await pauseFor(1000)
}
async diagnosticRepairDisk(): Promise<void> {
await pauseFor(1000)
}
async diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.getServerLogs(params)
}
// init
async initGetProgress(): Promise<RR.InitGetProgressRes> {
await pauseFor(250)
return {
progress: PROGRESS,
guid: 'init-progress-guid',
}
}
async initFollowLogs(): Promise<RR.FollowServerLogsRes> {
await pauseFor(2000)
return params.message
return {
startCursor: 'start-cursor',
guid: 'logs-guid',
}
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
return this.mockWsSource$.pipe(
shareReplay({ bufferSize: 1, refCount: true }),
)
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(50).pipe(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
if (index === 100) throw new Error('HAAHHA')
return Mock.ServerLogs[0]
}),
)
}
// server
async getSystemTime(
params: RR.GetSystemTimeReq,
@@ -248,7 +300,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -258,7 +310,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -268,11 +320,11 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
randomLogs(limit = 1): Log[] {
private randomLogs(limit = 1): Log[] {
const arrLength = Math.ceil(limit / Mock.ServerLogs.length)
const logs = new Array(arrLength)
.fill(Mock.ServerLogs)
@@ -374,12 +426,6 @@ export class MockApiService extends ApiService {
return null
}
async systemRebuild(
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes> {
return this.restartServer(params)
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
await pauseFor(2000)
return null
@@ -422,7 +468,7 @@ export class MockApiService extends ApiService {
}
}
async getEos(): Promise<RR.GetMarketplaceEosRes> {
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
await pauseFor(2000)
return Mock.MarketplaceEos
}
@@ -641,13 +687,13 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
let entries
if (Math.random() < 0.2) {
entries = Mock.PackageLogs
entries = Mock.ServerLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / Mock.PackageLogs.length)
? Math.ceil(params.limit / Mock.ServerLogs.length)
: 10
entries = new Array(arrLength)
.fill(Mock.PackageLogs)
.fill(Mock.ServerLogs)
.reduce((acc, val) => acc.concat(val), [])
}
return {
@@ -663,7 +709,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -673,7 +719,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
setTimeout(async () => {
this.updateProgress(params.id)
this.installProgress(params.id)
}, 1000)
const patch: Operation<
@@ -745,7 +791,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => {
setTimeout(async () => {
this.updateProgress(id)
this.installProgress(id)
}, 2000)
return {
@@ -1013,7 +1059,57 @@ export class MockApiService extends ApiService {
return '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e' // no significance, randomly generated
}
private async updateProgress(id: string): Promise<void> {
private async initProgress(): Promise<T.FullProgress> {
const progress = JSON.parse(JSON.stringify(PROGRESS))
for (let [i, phase] of progress.phases.entries()) {
if (
!phase.progress ||
typeof phase.progress !== 'object' ||
!phase.progress.total
) {
await pauseFor(2000)
progress.phases[i].progress = true
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length
progress.overall.done += step
}
} else {
const step = phase.progress.total / 4
while (phase.progress.done < phase.progress.total) {
await pauseFor(200)
phase.progress.done += step
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length / 4
progress.overall.done += step
}
if (phase.progress.done === phase.progress.total) {
await pauseFor(250)
progress.phases[i].progress = true
}
}
}
}
return progress
}
private async installProgress(id: string): Promise<void> {
const progress = JSON.parse(JSON.stringify(PROGRESS))
for (let [i, phase] of progress.phases.entries()) {
@@ -1194,10 +1290,6 @@ export class MockApiService extends ApiService {
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
if (!this.sequence) {
const { sequence } = this.bootstrapper.init()
this.sequence = sequence
}
const revision = {
id: ++this.sequence,
patch,

View File

@@ -12,7 +12,7 @@ export enum AuthState {
providedIn: 'root',
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly LOGGED_IN_KEY = 'loggedIn'
private readonly authState$ = new ReplaySubject<AuthState>(1)
readonly isVerified$ = this.authState$.pipe(

View File

@@ -1,25 +1,23 @@
import { Injectable } from '@angular/core'
import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
import { inject, Injectable } from '@angular/core'
import { combineLatest, Observable, shareReplay } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { NetworkService } from 'src/app/services/network.service'
import { StateService } from 'src/app/services/state.service'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
readonly networkConnected$ = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null),
map(() => navigator.onLine),
distinctUntilChanged(),
)
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
readonly connected$ = combineLatest([
this.networkConnected$,
this.websocketConnected$.pipe(distinctUntilChanged()),
export class ConnectionService extends Observable<boolean> {
private readonly stream$ = combineLatest([
inject(NetworkService),
inject(StateService).pipe(map(Boolean)),
]).pipe(
map(([network, websocket]) => network && websocket),
distinctUntilChanged(),
shareReplay(1),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { BehaviorSubject, combineLatest } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { MarketplaceEOS } from 'src/app/services/api/api.types'
import { OSUpdate } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import { getServerInfo } from 'src/app/util/get-server-info'
@@ -12,7 +12,7 @@ import { DataModel } from './patch-db/data-model'
providedIn: 'root',
})
export class EOSService {
eos?: MarketplaceEOS
osUpdate?: OSUpdate
updateAvailable$ = new BehaviorSubject<boolean>(false)
readonly updating$ = this.patch.watch$('serverInfo', 'statusInfo').pipe(
@@ -52,9 +52,10 @@ export class EOSService {
) {}
async loadEos(): Promise<void> {
const { version } = await getServerInfo(this.patch)
this.eos = await this.api.getEos()
const updateAvailable = this.emver.compare(this.eos.version, version) === 1
const { version, id } = await getServerInfo(this.patch)
this.osUpdate = await this.api.checkOSUpdate({ serverId: id })
const updateAvailable =
this.emver.compare(this.osUpdate.version, version) === 1
this.updateAvailable$.next(updateAvailable)
}
}

View File

@@ -0,0 +1,22 @@
import { inject, Injectable } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { fromEvent, merge, Observable, shareReplay } from 'rxjs'
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class NetworkService extends Observable<boolean> {
private readonly win = inject(WINDOW)
private readonly stream$ = merge(
fromEvent(this.win, 'online'),
fromEvent(this.win, 'offline'),
).pipe(
startWith(null),
map(() => this.win.navigator.onLine),
distinctUntilChanged(),
shareReplay(1),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,9 +1,9 @@
import { Inject, Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { Observable } from 'rxjs'
import { filter, share, switchMap, take, tap } from 'rxjs/operators'
import { filter, map, share, switchMap, take, tap } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page'
import { ConfigService } from 'src/app/services/config.service'
@@ -11,21 +11,25 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ConnectionService } from 'src/app/services/connection.service'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Get data from PatchDb after is starts and act upon it
@Injectable({
providedIn: 'root',
})
export class PatchDataService extends Observable<DataModel> {
private readonly stream$ = this.connectionService.connected$.pipe(
export class PatchDataService extends Observable<void> {
private readonly stream$ = this.connection$.pipe(
filter(Boolean),
switchMap(() => this.patch.watch$()),
take(1),
tap(({ ui }) => {
// check for updates to eOS and services
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(ui.ackWelcome)
map((cache, index) => {
this.bootstrapper.update(cache)
if (index === 0) {
// check for updates to StartOS and services
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(cache.ui.ackWelcome)
}
}),
share(),
)
@@ -38,7 +42,8 @@ export class PatchDataService extends Observable<DataModel> {
private readonly embassyApi: ApiService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly connectionService: ConnectionService,
private readonly connection$: ConnectionService,
private readonly bootstrapper: LocalStorageBootstrap,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}

View File

@@ -1,4 +1,4 @@
import { Bootstrapper, DBCache } from 'patch-db-client'
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Injectable } from '@angular/core'
import { StorageService } from '../storage.service'
@@ -6,20 +6,18 @@ import { StorageService } from '../storage.service'
@Injectable({
providedIn: 'root',
})
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
static CONTENT_KEY = 'patch-db-cache'
export class LocalStorageBootstrap {
static CONTENT_KEY = 'patchDB'
constructor(private readonly storage: StorageService) {}
init(): DBCache<DataModel> {
const cache = this.storage.get<DBCache<DataModel>>(
LocalStorageBootstrap.CONTENT_KEY,
)
init(): Dump<DataModel> {
const cache = this.storage.get<DataModel>(LocalStorageBootstrap.CONTENT_KEY)
return cache || { sequence: 0, data: {} as DataModel }
return cache ? { id: 1, value: cache } : { id: 0, value: {} as DataModel }
}
update(cache: DBCache<DataModel>): void {
update(cache: DataModel): void {
this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
}
}

View File

@@ -1,19 +1,19 @@
import { InjectionToken, Injector } from '@angular/core'
import { Revision, Update } from 'patch-db-client'
import { defer, EMPTY, from, Observable } from 'rxjs'
import {
bufferTime,
catchError,
filter,
startWith,
switchMap,
take,
tap,
} from 'rxjs/operators'
import { Update } from 'patch-db-client'
import { DataModel } from './data-model'
import { defer, EMPTY, from, interval, Observable } from 'rxjs'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { StateService } from 'src/app/services/state.service'
import { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service'
import { AuthService } from '../auth.service'
import { DataModel } from './data-model'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
'',
@@ -25,33 +25,31 @@ export function sourceFactory(
// defer() needed to avoid circular dependency with ApiService, since PatchDB is needed there
return defer(() => {
const api = injector.get(ApiService)
const authService = injector.get(AuthService)
const connectionService = injector.get(ConnectionService)
const configService = injector.get(ConfigService)
const isTor = configService.isTor()
const timeout = isTor ? 16000 : 4000
const auth = injector.get(AuthService)
const state = injector.get(StateService)
const bootstrapper = injector.get(LocalStorageBootstrap)
const websocket$ = api.openPatchWebsocket$().pipe(
bufferTime(250),
filter(updates => !!updates.length),
catchError((_, watch$) => {
connectionService.websocketConnected$.next(false)
return auth.isVerified$.pipe(
switchMap(verified =>
verified ? from(api.subscribeToPatchDB({})) : EMPTY,
),
switchMap(({ dump, guid }) =>
api.openWebsocket$<Revision>(guid, {}).pipe(
bufferTime(250),
filter(revisions => !!revisions.length),
startWith([dump]),
),
),
catchError((_, original$) => {
state.retrigger()
return interval(timeout).pipe(
switchMap(() =>
from(api.echo({ message: 'ping', timeout })).pipe(
catchError(() => EMPTY),
),
),
return state.pipe(
filter(current => current === 'running'),
take(1),
switchMap(() => watch$),
switchMap(() => original$),
)
}),
tap(() => connectionService.websocketConnected$.next(true)),
)
return authService.isVerified$.pipe(
switchMap(verified => (verified ? websocket$ : EMPTY)),
startWith([bootstrapper.init()]),
)
})
}

View File

@@ -4,24 +4,19 @@ import { tap } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { AuthService } from 'src/app/services/auth.service'
import { DataModel } from './patch-db/data-model'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Start and stop PatchDb upon verification
@Injectable({
providedIn: 'root',
})
export class PatchMonitorService extends Observable<any> {
// @TODO not happy with Observable<void>
export class PatchMonitorService extends Observable<unknown> {
private readonly stream$ = this.authService.isVerified$.pipe(
tap(verified =>
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),
),
tap(verified => (verified ? this.patch.start() : this.patch.stop())),
)
constructor(
private readonly authService: AuthService,
private readonly patch: PatchDB<DataModel>,
private readonly bootstrapper: LocalStorageBootstrap,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}

View File

@@ -0,0 +1,136 @@
import { inject, Injectable } from '@angular/core'
import { CanActivateFn, IsActiveMatchOptions, Router } from '@angular/router'
import { ALWAYS_TRUE_HANDLER } from '@taiga-ui/cdk'
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
import {
BehaviorSubject,
combineLatest,
concat,
EMPTY,
exhaustMap,
from,
merge,
Observable,
startWith,
Subject,
timer,
} from 'rxjs'
import {
catchError,
filter,
map,
shareReplay,
skip,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { NetworkService } from 'src/app/services/network.service'
const OPTIONS: IsActiveMatchOptions = {
paths: 'subset',
queryParams: 'exact',
fragment: 'ignored',
matrixParams: 'ignored',
}
@Injectable({
providedIn: 'root',
})
export class StateService extends Observable<RR.ServerState | null> {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly network$ = inject(NetworkService)
private readonly single$ = new Subject<RR.ServerState>()
private readonly trigger$ = new BehaviorSubject<void>(undefined)
private readonly poll$ = this.trigger$.pipe(
switchMap(() =>
timer(0, 2000).pipe(
switchMap(() =>
from(this.api.getState()).pipe(catchError(() => EMPTY)),
),
take(1),
),
),
)
private readonly stream$ = merge(this.single$, this.poll$).pipe(
tap(state => {
switch (state) {
case 'initializing':
this.router.navigate(['initializing'], { replaceUrl: true })
break
case 'error':
this.router.navigate(['diagnostic'], { replaceUrl: true })
break
case 'running':
if (
this.router.isActive('initializing', OPTIONS) ||
this.router.isActive('diagnostic', OPTIONS)
) {
this.router.navigate([''], { replaceUrl: true })
}
break
}
}),
startWith(null),
shareReplay(1),
)
private readonly alert = merge(
this.trigger$.pipe(skip(1)),
this.network$.pipe(filter(v => !v)),
)
.pipe(
exhaustMap(() =>
concat(
this.alerts
.open('Trying to reach server', {
label: 'State unknown',
autoClose: false,
status: TuiNotification.Error,
})
.pipe(
takeUntil(
combineLatest([this.stream$, this.network$]).pipe(
filter(state => state.every(Boolean)),
),
),
),
this.alerts.open('Connection restored', {
label: 'Server reached',
status: TuiNotification.Success,
}),
),
),
)
.subscribe() // @TODO shouldn't this be subscribed in app component with the others? Do we ever need to unsubscribe?
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
retrigger() {
this.trigger$.next()
}
async syncState() {
const state = await this.api.getState()
this.single$.next(state)
}
}
export function stateNot(state: RR.ServerState[]): CanActivateFn {
return () =>
inject(StateService).pipe(
filter(current => !current || !state.includes(current)),
map(ALWAYS_TRUE_HANDLER),
)
}

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
const PREFIX = '_embassystorage/_embassykv/'
const PREFIX = '_startos/'
@Injectable({
providedIn: 'root',
@@ -15,16 +15,21 @@ export class StorageService {
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))
}
set<T>(key: string, value: T) {
set(key: string, value: any) {
this.storage.setItem(`${PREFIX}${key}`, JSON.stringify(value))
}
clear() {
Array.from(
{ length: this.storage.length },
(_, i) => this.storage.key(i) || '',
)
.filter(key => key.startsWith(PREFIX))
.forEach(key => this.storage.removeItem(key))
this.storage.clear()
}
migrate036() {
const oldPrefix = '_embassystorage/_embassykv/'
if (!!this.storage.getItem(`${oldPrefix}loggedInKey`)) {
const cache = this.storage.getItem(`${oldPrefix}patch-db-cache`)
this.clear()
this.set('loggedIn', true)
this.set('patchDB', cache)
}
}
}