rename frontend to web and update contributing guide (#2509)

* rename frontend to web and update contributing guide

* rename this time

* fix build

* restructure rust code

* update documentation

* update descriptions

* Update CONTRIBUTING.md

Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>
This commit is contained in:
Matt Hill
2023-11-13 14:22:23 -07:00
committed by GitHub
parent 871f78b570
commit 86567e7fa5
968 changed files with 812 additions and 6672 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
import { Dump, Revision } 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,
HealthCheckResult,
Manifest,
} from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
export module RR {
// DB
export type GetRevisionsRes = Revision[] | Dump<DataModel>
export type GetDumpRes = Dump<DataModel>
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
export type SetDBValueRes = null
// auth
export type LoginReq = {
password: string
metadata: SessionMetadata
} // auth.login - unauthed
export type loginRes = null
export type LogoutReq = {} // auth.logout
export type LogoutRes = null
export type ResetPasswordReq = {
'old-password': string
'new-password': string
} // auth.reset-password
export type ResetPasswordRes = null
// server
export type EchoReq = { message: string; timeout?: number } // server.echo
export type EchoRes = string
export type GetSystemTimeReq = {} // server.time
export type GetSystemTimeRes = {
now: string
uptime: number // seconds
}
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = LogsRes
export type FollowServerLogsReq = { limit?: number } // server.logs.follow & server.kernel-logs.follow
export type FollowServerLogsRes = {
'start-cursor': string
guid: string
}
export type GetServerMetricsReq = {} // server.metrics
export type GetServerMetricsRes = Metrics
export type UpdateServerReq = { 'marketplace-url': string } // server.update
export type UpdateServerRes = 'updating' | 'no-updates'
export type RestartServerReq = {} // server.restart
export type RestartServerRes = null
export type ShutdownServerReq = {} // server.shutdown
export type ShutdownServerRes = null
export type SystemRebuildReq = {} // server.rebuild
export type SystemRebuildRes = null
export type ResetTorReq = {
'wipe-state': boolean
reason: string
} // net.tor.reset
export type ResetTorRes = null
export type ToggleZramReq = {
enable: boolean
} // server.experimental.zram
export type ToggleZramRes = null
// sessions
export type GetSessionsReq = {} // sessions.list
export type GetSessionsRes = {
current: string
sessions: { [hash: string]: Session }
}
export type KillSessionsReq = { ids: string[] } // sessions.kill
export type KillSessionsRes = null
// notification
export type GetNotificationsReq = {
before?: number
limit?: number
} // notification.list
export type GetNotificationsRes = ServerNotification<number>[]
export type DeleteNotificationReq = { id: number } // notification.delete
export type DeleteNotificationRes = null
export type DeleteAllNotificationsReq = { before: number } // notification.delete-before
export type DeleteAllNotificationsRes = null
// wifi
export type SetWifiCountryReq = { country: string }
export type SetWifiCountryRes = null
export type GetWifiReq = {}
export type GetWifiRes = {
ssids: {
[ssid: string]: number
}
connected: string | null
country: string | null
ethernet: boolean
'available-wifi': AvailableWifi[]
}
export type AddWifiReq = {
// wifi.add
ssid: string
password: string
priority: number
connect: boolean
}
export type AddWifiRes = null
export type ConnectWifiReq = { ssid: string } // wifi.connect
export type ConnectWifiRes = null
export type DeleteWifiReq = { ssid: string } // wifi.delete
export type DeleteWifiRes = null
// ssh
export type GetSSHKeysReq = {} // ssh.list
export type GetSSHKeysRes = SSHKey[]
export type AddSSHKeyReq = { key: string } // ssh.add
export type AddSSHKeyRes = SSHKey
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.delete
export type DeleteSSHKeyRes = null
// backup
export type GetBackupTargetsReq = {} // backup.target.list
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
export type AddBackupTargetReq = {
// backup.target.cifs.add
hostname: string
path: string
username: string
password: string | null
}
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null
export type GetBackupInfoReq = { 'target-id': string; password: string } // backup.target.info
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = {
// backup.create
'target-id': string
'package-ids': string[]
'old-password': string | null
password: string
}
export type CreateBackupRes = null
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
export type InstallPackageReq = {
id: string
'version-spec'?: string
'version-priority'?: 'min' | 'max'
'marketplace-url': string
} // package.install
export type InstallPackageRes = null
export type GetPackageConfigReq = { id: string } // package.config.get
export type GetPackageConfigRes = { spec: ConfigSpec; config: object }
export type DrySetPackageConfigReq = { id: string; config: object } // package.config.set.dry
export type DrySetPackageConfigRes = Breakages
export type SetPackageConfigReq = DrySetPackageConfigReq // package.config.set
export type SetPackageConfigRes = null
export type RestorePackagesReq = {
// package.backup.restore
ids: string[]
'target-id': string
'old-password': string | null
password: string
}
export type RestorePackagesRes = null
export type ExecutePackageActionReq = {
id: string
'action-id': string
input?: object
} // package.action
export type ExecutePackageActionRes = ActionResponse
export type StartPackageReq = { id: string } // package.start
export type StartPackageRes = null
export type RestartPackageReq = { id: string } // package.restart
export type RestartPackageRes = null
export type StopPackageReq = { id: string } // package.stop
export type StopPackageRes = null
export type UninstallPackageReq = { id: string } // package.uninstall
export type UninstallPackageRes = null
export type DryConfigureDependencyReq = {
'dependency-id': string
'dependent-id': string
} // package.dependency.configure.dry
export type DryConfigureDependencyRes = {
'old-config': object
'new-config': object
spec: ConfigSpec
}
export type SideloadPackageReq = {
manifest: Manifest
icon: string // base64
}
export type SideloadPacakgeRes = string //guid
// marketplace
export type GetMarketplaceInfoReq = { 'server-id': string }
export type GetMarketplaceInfoRes = StoreInfo
export type GetMarketplaceEosReq = { 'server-id': string }
export type GetMarketplaceEosRes = MarketplaceEOS
export type GetMarketplacePackagesReq = {
ids?: { id: string; version: string }[]
// iff !ids
category?: string
query?: string
page?: number
'per-page'?: number
}
export type GetMarketplacePackagesRes = MarketplacePkg[]
export type GetReleaseNotesReq = { id: string }
export type GetReleaseNotesRes = { [version: string]: string }
}
export interface MarketplaceEOS {
version: string
headline: string
'release-notes': { [version: string]: string }
}
export interface Breakages {
[id: string]: TaggedDependencyError
}
export interface TaggedDependencyError {
dependency: string
error: DependencyError
}
export interface ActionResponse {
message: string
value: string | null
copyable: boolean
qr: boolean
}
interface MetricData {
value: string
unit: string
}
export interface Metrics {
general: {
temperature: MetricData | null
}
memory: {
total: MetricData
'percentage-used': MetricData
used: MetricData
available: MetricData
'zram-total': MetricData
'zram-used': MetricData
'zram-available': MetricData
}
cpu: {
'percentage-used': MetricData
idle: MetricData
'user-space': MetricData
'kernel-space': MetricData
wait: MetricData
}
disk: {
capacity: MetricData
'percentage-used': MetricData
used: MetricData
available: MetricData
}
}
export interface Metric {
[key: string]: {
value: string | number | null
unit?: string
}
}
export interface Session {
'last-active': string
'user-agent': string
metadata: SessionMetadata
}
export interface SessionMetadata {
platforms: PlatformType[]
}
export type PlatformType =
| 'cli'
| 'ios'
| 'ipad'
| 'iphone'
| 'android'
| 'phablet'
| 'tablet'
| 'cordova'
| 'capacitor'
| 'electron'
| 'pwa'
| 'mobile'
| 'mobileweb'
| 'desktop'
| 'hybrid'
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export interface DiskBackupTarget {
type: 'disk'
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
'embassy-os': StartOSDiskInfo | null
}
export interface CifsBackupTarget {
type: 'cifs'
hostname: string
path: string
username: string
mountable: boolean
'embassy-os': StartOSDiskInfo | null
}
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
export interface DiskRecoverySource {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsRecoverySource {
type: 'cifs'
hostname: string
path: string
username: string
password: string
}
export interface BackupInfo {
version: string
timestamp: string
'package-backups': {
[id: string]: PackageBackupInfo
}
}
export interface PackageBackupInfo {
title: string
version: string
'os-version': string
timestamp: string
}
export interface ServerSpecs {
[key: string]: string | number
}
export interface SSHKey {
'created-at': string
alg: string
hostname: string
fingerprint: string
}
export type ServerNotifications = ServerNotification<any>[]
export interface ServerNotification<T extends number> {
id: number
'package-id': string | null
'created-at': string
code: T
level: NotificationLevel
title: string
message: string
data: NotificationData<T>
}
export enum NotificationLevel {
Success = 'success',
Info = 'info',
Warning = 'warning',
Error = 'error',
}
export type NotificationData<T> = T extends 0
? null
: T extends 1
? BackupReport
: any
export interface BackupReport {
server: {
attempted: boolean
error: string | null
}
packages: {
[id: string]: {
error: string | null
}
}
}
export interface AvailableWifi {
ssid: string
strength: number
security: string[]
}
declare global {
type Stringified<T> = string & {
[P in keyof T]: T[P]
}
interface JSON {
stringify<T>(
value: T,
replacer?: (key: string, value: any) => any,
space?: string | number,
): string & Stringified<T>
parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T
}
}
export type Encrypted = {
encrypted: string
}
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'not-installed',
NotRunning = 'not-running',
IncorrectVersion = 'incorrect-version',
ConfigUnsatisfied = 'config-unsatisfied',
HealthChecksFailed = 'health-checks-failed',
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
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
error: string
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
check: HealthCheckResult
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}

View File

@@ -0,0 +1,232 @@
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
// for getting static files: ex icons, instructions, licenses
abstract getStatic(url: string): Promise<string>
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<string>
// db
abstract setDbValue<T>(
pathArr: Array<string | number>,
value: T,
): Promise<RR.SetDBValueRes>
// auth
abstract login(params: RR.LoginReq): Promise<RR.loginRes>
abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes>
abstract getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes>
abstract killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes>
abstract resetPassword(
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes>
// 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>
abstract getServerLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract getKernelLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
abstract followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes>
abstract getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes>
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
abstract restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes>
abstract shutdownServer(
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes>
abstract systemRebuild(
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes>
abstract repairDisk(params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
abstract toggleZram(params: RR.ToggleZramReq): Promise<RR.ToggleZramRes>
// marketplace URLs
abstract marketplaceProxy<T>(
path: string,
params: Record<string, unknown>,
url: string,
): Promise<T>
abstract getEos(): Promise<RR.GetMarketplaceEosRes>
// notification
abstract getNotifications(
params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes>
abstract deleteNotification(
params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes>
abstract deleteAllNotifications(
params: RR.DeleteAllNotificationsReq,
): Promise<RR.DeleteAllNotificationsRes>
// wifi
abstract getWifi(
params: RR.GetWifiReq,
timeout: number,
): Promise<RR.GetWifiRes>
abstract setWifiCountry(
params: RR.SetWifiCountryReq,
): Promise<RR.SetWifiCountryRes>
abstract addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes>
abstract connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
abstract deleteWifi(params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
// ssh
abstract getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes>
abstract addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes>
abstract deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes>
// backup
abstract getBackupTargets(
params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes>
abstract addBackupTarget(
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes>
abstract updateBackupTarget(
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes>
abstract removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes>
abstract getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes>
abstract createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
// package
abstract getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']>
abstract getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
abstract followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes>
abstract installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
abstract getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes>
abstract drySetPackageConfig(
params: RR.DrySetPackageConfigReq,
): Promise<RR.DrySetPackageConfigRes>
abstract setPackageConfig(
params: RR.SetPackageConfigReq,
): Promise<RR.SetPackageConfigRes>
abstract restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes>
abstract executePackageAction(
params: RR.ExecutePackageActionReq,
): Promise<RR.ExecutePackageActionRes>
abstract startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes>
abstract restartPackage(
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes>
abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes>
abstract uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes>
abstract dryConfigureDependency(
params: RR.DryConfigureDependencyReq,
): Promise<RR.DryConfigureDependencyRes>
abstract sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes>
}

View File

@@ -0,0 +1,464 @@
import { Inject, Injectable } from '@angular/core'
import {
HttpOptions,
HttpService,
isRpcError,
Log,
Method,
RpcError,
RPCOptions,
} from '@start9labs/shared'
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 { 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'
@Injectable()
export class LiveApiService extends ApiService {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly auth: AuthService,
private readonly patch: PatchDB<DataModel>,
) {
super()
; (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,
url,
responseType: 'text',
})
}
// for sideloading packages
async uploadPackage(guid: string, body: Blob): Promise<string> {
return this.httpRequest({
method: Method.POST,
body,
url: `/rest/rpc/${guid}`,
responseType: 'text',
})
}
// db
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
): Promise<RR.SetDBValueRes> {
const pointer = pathFromArray(pathArr)
const params: RR.SetDBValueReq<T> = { pointer, value }
return this.rpcRequest({ method: 'db.put.ui', params })
}
// auth
async login(params: RR.LoginReq): Promise<RR.loginRes> {
return this.rpcRequest({ method: 'auth.login', params })
}
async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> {
return this.rpcRequest({ method: 'auth.logout', params })
}
async getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
return this.rpcRequest({ method: 'auth.session.list', params })
}
async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
return this.rpcRequest({ method: 'auth.session.kill', params })
}
async resetPassword(
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes> {
return this.rpcRequest({ method: 'auth.reset-password', 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> {
return this.rpcRequest({ method: 'server.time', params })
}
async getServerLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.rpcRequest({ method: 'server.logs', params })
}
async getKernelLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.rpcRequest({ method: 'server.kernel-logs', params })
}
async getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs', params })
}
async followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'server.logs.follow', params })
}
async followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'server.kernel-logs.follow', params })
}
async followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs.follow', params })
}
async getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> {
return this.rpcRequest({ method: 'server.metrics', params })
}
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
const params = {
'marketplace-url': url || this.config.marketplace.start9,
}
return this.rpcRequest({ method: 'server.update', params })
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
return this.rpcRequest({ method: 'server.restart', params })
}
async shutdownServer(
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes> {
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 })
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
return this.rpcRequest({ method: 'net.tor.reset', params })
}
async toggleZram(params: RR.ToggleZramReq): Promise<RR.ToggleZramRes> {
return this.rpcRequest({ method: 'server.experimental.zram', params })
}
// marketplace URLs
async marketplaceProxy<T>(
path: string,
qp: Record<string, string>,
baseUrl: string,
): Promise<T> {
const fullUrl = `${baseUrl}${path}?${new URLSearchParams(qp).toString()}`
return this.rpcRequest({
method: 'marketplace.get',
params: { url: fullUrl },
})
}
async getEos(): Promise<RR.GetMarketplaceEosRes> {
const { id } = await getServerInfo(this.patch)
const qp: RR.GetMarketplaceEosReq = { 'server-id': id }
return this.marketplaceProxy(
'/eos/v0/latest',
qp,
this.config.marketplace.start9,
)
}
// notification
async getNotifications(
params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes> {
return this.rpcRequest({ method: 'notification.list', params })
}
async deleteNotification(
params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes> {
return this.rpcRequest({ method: 'notification.delete', params })
}
async deleteAllNotifications(
params: RR.DeleteAllNotificationsReq,
): Promise<RR.DeleteAllNotificationsRes> {
return this.rpcRequest({
method: 'notification.delete-before',
params,
})
}
// wifi
async getWifi(
params: RR.GetWifiReq,
timeout?: number,
): Promise<RR.GetWifiRes> {
return this.rpcRequest({ method: 'wifi.get', params, timeout })
}
async setWifiCountry(
params: RR.SetWifiCountryReq,
): Promise<RR.SetWifiCountryRes> {
return this.rpcRequest({ method: 'wifi.country.set', params })
}
async addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
return this.rpcRequest({ method: 'wifi.add', params })
}
async connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
return this.rpcRequest({ method: 'wifi.connect', params })
}
async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.rpcRequest({ method: 'wifi.delete', params })
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
return this.rpcRequest({ method: 'ssh.list', params })
}
async addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
return this.rpcRequest({ method: 'ssh.add', params })
}
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.rpcRequest({ method: 'ssh.delete', params })
}
// backup
async getBackupTargets(
params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes> {
return this.rpcRequest({ method: 'backup.target.list', params })
}
async addBackupTarget(
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/')
return this.rpcRequest({ method: 'backup.target.cifs.add', params })
}
async updateBackupTarget(
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
return this.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> {
return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes> {
return this.rpcRequest({ method: 'backup.target.info', params })
}
async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
return this.rpcRequest({ method: 'backup.create', params })
}
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
return this.rpcRequest({ method: 'package.properties', params }).then(
parsePropertiesPermissive,
)
}
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
return this.rpcRequest({ method: 'package.logs', params })
}
async followPackageLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'package.logs.follow', params })
}
async getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
return this.rpcRequest({ method: 'package.metrics', params })
}
async installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
return this.rpcRequest({ method: 'package.install', params })
}
async getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes> {
return this.rpcRequest({ method: 'package.config.get', params })
}
async drySetPackageConfig(
params: RR.DrySetPackageConfigReq,
): Promise<RR.DrySetPackageConfigRes> {
return this.rpcRequest({ method: 'package.config.set.dry', params })
}
async setPackageConfig(
params: RR.SetPackageConfigReq,
): Promise<RR.SetPackageConfigRes> {
return this.rpcRequest({ method: 'package.config.set', params })
}
async restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {
return this.rpcRequest({ method: 'package.backup.restore', params })
}
async executePackageAction(
params: RR.ExecutePackageActionReq,
): Promise<RR.ExecutePackageActionRes> {
return this.rpcRequest({ method: 'package.action', params })
}
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
return this.rpcRequest({ method: 'package.start', params })
}
async restartPackage(
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> {
return this.rpcRequest({ method: 'package.restart', params })
}
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
return this.rpcRequest({ method: 'package.stop', params })
}
async uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> {
return this.rpcRequest({ method: 'package.uninstall', params })
}
async dryConfigureDependency(
params: RR.DryConfigureDependencyReq,
): Promise<RR.DryConfigureDependencyRes> {
return this.rpcRequest({
method: 'package.dependency.configure.dry',
params,
})
}
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
return this.rpcRequest({
method: 'package.sideload',
params,
})
}
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,
): Promise<T> {
const res = await this.http.rpcRequest<T>(options, urlOverride)
const body = res.body
if (isRpcError(body)) {
if (body.error.code === 34) {
console.error('Unauthenticated, logging out')
this.auth.setUnverified()
}
throw new RpcError(body.error)
}
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.patch.cache$.pipe(
filter(({ sequence }) => sequence >= Number(patchSequence)),
),
)
return body.result
}
private async httpRequest<T>(opts: HttpOptions): Promise<T> {
const res = await this.http.httpRequest<T>(opts)
return res.body
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,682 @@
import {
DataModel,
DockerIoFormat,
HealthResult,
PackageMainStatus,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures'
import { BUILT_IN_WIDGETS } from '../../pages/widgets/built-in/widgets'
export const mockPatchData: DataModel = {
ui: {
name: `Matt's Server`,
'ack-welcome': '1.0.0',
theme: 'Dark',
widgets: BUILT_IN_WIDGETS.filter(
({ id }) =>
id === 'favorites' ||
id === 'health' ||
id === 'network' ||
id === 'metrics',
),
marketplace: {
'selected-url': 'https://registry.start9.com/',
'known-hosts': {
'https://registry.start9.com/': {
name: 'Start9 Registry',
},
'https://community-registry.start9.com/': {},
'https://beta-registry.start9.com/': {
name: 'Dark9',
},
},
},
dev: {},
gaming: {
snake: {
'high-score': 0,
},
},
'ack-instructions': {},
},
'server-info': {
id: 'abcdefgh',
version: '0.3.5',
'last-backup': new Date(new Date().valueOf() - 604800001).toISOString(),
'lan-address': 'https://adjective-noun.local',
'tor-address': 'https://myveryownspecialtoraddress.onion',
'ip-info': {
eth0: {
ipv4: '10.0.0.1',
ipv6: null,
},
wlan0: {
ipv4: '10.0.90.12',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD',
},
},
'last-wifi-region': null,
'unread-notification-count': 4,
// password is asdfasdf
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'eos-version-compat': '>=0.3.0 <=0.3.0.1',
'status-info': {
'backup-progress': null,
updated: false,
'update-progress': null,
restarting: false,
'shutting-down': false,
},
hostname: 'random-words',
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
'ca-fingerprint': 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
'ntp-synced': false,
zram: false,
platform: 'x86_64-nonfree',
},
'package-data': {
bitcoind: {
state: PackageState.Installed,
'static-files': {
license: '/public/package-data/bitcoind/0.20.0/LICENSE.md',
icon: '/assets/img/service-icons/bitcoind.svg',
instructions: '/public/package-data/bitcoind/0.20.0/INSTRUCTIONS.md',
},
manifest: {
id: 'bitcoind',
title: 'Bitcoin Core',
version: '0.20.0',
'git-hash': 'abcdefgh',
description: {
short: 'A Bitcoin full node by Bitcoin Core.',
long: 'Bitcoin is a decentralized consensus protocol and settlement network.',
},
'release-notes': 'Taproot, Schnorr, and more.',
assets: {
icon: 'icon.png',
license: 'LICENSE.md',
instructions: 'INSTRUCTIONS.md',
docker_images: 'image.tar',
assets: './assets',
scripts: './scripts',
},
license: 'MIT',
'wrapper-repo': 'https://github.com/start9labs/bitcoind-wrapper',
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
'support-site': 'https://bitcoin.org',
'marketing-site': 'https://bitcoin.org',
'donation-url': 'https://start9.com',
alerts: {
install: 'Bitcoin can take over a week to sync.',
uninstall:
'Chain state will be lost, as will any funds stored on your Bitcoin Core waller that have not been backed up.',
restore: null,
start: 'Starting Bitcoin is good for your health.',
stop: null,
},
main: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': '.49m',
},
'health-checks': {
'chain-state': {
name: 'Chain State',
},
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
},
'p2p-interface': {
name: 'P2P Interface',
'success-message': 'the health check ran succesfully',
},
'rpc-interface': {
name: 'RPC Interface',
},
'unnecessary-health-check': {
name: 'Unneccessary Health Check',
},
} as any,
config: {
get: {},
set: {},
} as any,
volumes: {},
'min-os-version': '0.2.12',
interfaces: {
ui: {
name: 'Node Visualizer',
description:
'Web application for viewing information about your node and the Bitcoin network.',
ui: true,
'tor-config': {
'port-mapping': {},
},
'lan-config': {},
protocols: [],
},
rpc: {
name: 'RPC',
description:
'Used by wallets to interact with your Bitcoin Core node.',
ui: false,
'tor-config': {
'port-mapping': {},
},
'lan-config': {},
protocols: [],
},
p2p: {
name: 'P2P',
description:
'Used by other Bitcoin nodes to communicate and interact with your node.',
ui: false,
'tor-config': {
'port-mapping': {},
},
'lan-config': {},
protocols: [],
},
},
backup: {
create: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
restore: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
},
migrations: null,
actions: {
resync: {
name: 'Resync Blockchain',
description:
'Use this to resync the Bitcoin blockchain from genesis',
warning: 'This will take a couple of days.',
'allowed-statuses': [
PackageMainStatus.Running,
PackageMainStatus.Stopped,
],
implementation: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
'input-spec': {
reason: {
type: 'string',
name: 'Re-sync Reason',
description:
'Your reason for re-syncing. Why are you doing this?',
nullable: false,
masked: false,
copyable: false,
pattern: '^[a-zA-Z]+$',
'pattern-description': 'Must contain only letters.',
},
name: {
type: 'string',
name: 'Your Name',
description: 'Tell the class your name.',
nullable: true,
masked: false,
copyable: false,
warning: 'You may loose all your money by providing your name.',
},
notifications: {
name: 'Notification Preferences',
type: 'list',
subtype: 'enum',
description: 'how you want to be notified',
range: '[1,3]',
default: ['email'],
spec: {
'value-names': {
email: 'Email',
text: 'Text',
call: 'Call',
push: 'Push',
webhook: 'Webhook',
},
values: ['email', 'text', 'call', 'push', 'webhook'],
},
},
'days-ago': {
type: 'number',
name: 'Days Ago',
description: 'Number of days to re-sync.',
nullable: false,
default: 100,
range: '[0, 9999]',
integral: true,
},
'top-speed': {
type: 'number',
name: 'Top Speed',
description: 'The fastest you can possibly run.',
nullable: false,
range: '[-1000, 1000]',
integral: false,
units: 'm/s',
},
testnet: {
name: 'Testnet',
type: 'boolean',
description:
'<ul><li>determines whether your node is running on testnet or mainnet</li></ul><script src="fake"></script>',
warning: 'Chain will have to resync!',
default: false,
},
randomEnum: {
name: 'Random Enum',
type: 'enum',
'value-names': {
null: 'Null',
good: 'Good',
bad: 'Bad',
ugly: 'Ugly',
},
default: 'null',
description: 'This is not even real.',
warning: 'Be careful changing this!',
values: ['null', 'good', 'bad', 'ugly'],
},
'emergency-contact': {
name: 'Emergency Contact',
type: 'object',
description: 'The person to contact in case of emergency.',
spec: {
name: {
type: 'string',
name: 'Name',
nullable: false,
masked: false,
copyable: false,
pattern: '^[a-zA-Z]+$',
'pattern-description': 'Must contain only letters.',
},
email: {
type: 'string',
name: 'Email',
nullable: false,
masked: false,
copyable: true,
},
},
},
ips: {
name: 'Whitelist IPs',
type: 'list',
subtype: 'string',
description:
'external ip addresses that are authorized to access your Bitcoin node',
warning:
'Any IP you allow here will have RPC access to your Bitcoin node.',
range: '[1,10]',
default: ['192.168.1.1'],
spec: {
pattern: '^[0-9]{1,3}([,.][0-9]{1,3})?$',
'pattern-description': 'Must be a valid IP address',
masked: false,
copyable: false,
},
},
bitcoinNode: {
type: 'union',
default: 'internal',
tag: {
id: 'type',
'variant-names': {
internal: 'Internal',
external: 'External',
},
name: 'Bitcoin Node Settings',
description: 'The node settings',
warning: 'Careful changing this',
},
variants: {
internal: {
'lan-address': {
name: 'LAN Address',
type: 'pointer',
subtype: 'package',
target: 'lan-address',
'package-id': 'bitcoind',
description: 'the lan address',
interface: '',
},
'friendly-name': {
name: 'Friendly Name',
type: 'string',
description: 'the lan address',
nullable: true,
masked: false,
copyable: false,
},
},
external: {
'public-domain': {
name: 'Public Domain',
type: 'string',
description: 'the public address of the node',
nullable: false,
default: 'bitcoinnode.com',
pattern: '.*',
'pattern-description': 'anything',
masked: false,
copyable: true,
},
},
},
},
},
},
},
dependencies: {},
},
installed: {
manifest: {
...Mock.MockManifestBitcoind,
version: '0.20.0',
},
'last-backup': null,
status: {
configured: true,
main: {
status: PackageMainStatus.Running,
started: '2021-06-14T20:49:17.774Z',
health: {
'ephemeral-health-check': {
result: HealthResult.Starting,
},
'chain-state': {
result: HealthResult.Loading,
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
result: HealthResult.Success,
},
'rpc-interface': {
result: HealthResult.Failure,
error: 'RPC interface unreachable.',
},
'unnecessary-health-check': {
result: HealthResult.Disabled,
},
},
},
'dependency-config-errors': {},
},
'interface-addresses': {
ui: {
'tor-address': 'bitcoind-ui-address.onion',
'lan-address': 'bitcoind-ui-address.local',
},
rpc: {
'tor-address': 'bitcoind-rpc-address.onion',
'lan-address': 'bitcoind-rpc-address.local',
},
p2p: {
'tor-address': 'bitcoind-p2p-address.onion',
'lan-address': 'bitcoind-p2p-address.local',
},
},
'system-pointers': [],
'current-dependents': {
lnd: {
pointers: [],
'health-checks': [],
},
},
'current-dependencies': {},
'dependency-info': {},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
},
lnd: {
state: PackageState.Installed,
'static-files': {
license: '/public/package-data/lnd/0.11.1/LICENSE.md',
icon: '/assets/img/service-icons/lnd.png',
instructions: '/public/package-data/lnd/0.11.1/INSTRUCTIONS.md',
},
manifest: {
id: 'lnd',
title: 'Lightning Network Daemon',
version: '0.11.0',
description: {
short: 'A bolt spec compliant client.',
long: 'More info about LND. More info about LND. More info about LND.',
},
'release-notes': 'Dual funded channels!',
assets: {
icon: 'icon.png',
license: 'LICENSE.md',
instructions: 'INSTRUCTIONS.md',
docker_images: 'image.tar',
assets: './assets',
scripts: './scripts',
},
license: 'MIT',
'wrapper-repo': 'https://github.com/start9labs/lnd-wrapper',
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
'support-site': 'https://lightning.engineering/',
'marketing-site': 'https://lightning.engineering/',
'donation-url': null,
alerts: {
install: null,
uninstall: null,
restore:
'If this is a duplicate instance of the same LND node, you may loose your funds.',
start: 'Starting LND is good for your health.',
stop: null,
},
main: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': '0.5s',
},
'health-checks': {},
config: {
get: null,
set: null,
},
volumes: {},
'min-os-version': '0.2.12',
interfaces: {
rpc: {
name: 'RPC interface',
description: 'Good for connecting to your node at a distance.',
ui: true,
'tor-config': {
'port-mapping': {},
},
'lan-config': {
'44': {
ssl: true,
mapping: 33,
},
},
protocols: [],
},
grpc: {
name: 'GRPC',
description: 'Certain wallet use grpc.',
ui: false,
'tor-config': {
'port-mapping': {},
},
'lan-config': {
'66': {
ssl: true,
mapping: 55,
},
},
protocols: [],
},
},
backup: {
create: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
restore: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
},
migrations: null,
actions: {
resync: {
name: 'Resync Network Graph',
description: 'Your node will resync its network graph.',
warning: 'This will take a couple hours.',
'allowed-statuses': [PackageMainStatus.Running],
implementation: {
type: 'docker',
image: '',
system: true,
entrypoint: '',
args: [],
mounts: {},
'io-format': DockerIoFormat.Yaml,
inject: false,
'shm-size': '',
'sigterm-timeout': null,
},
'input-spec': null,
},
},
dependencies: {
bitcoind: {
version: '=0.21.0',
description: 'LND needs bitcoin to live.',
requirement: {
type: 'opt-out',
how: 'You can use an external node from your server if you prefer.',
},
config: null,
},
'btc-rpc-proxy': {
version: '>=0.2.2',
description:
'As long as Bitcoin is pruned, LND needs Bitcoin Proxy to fetch block over the P2P network.',
requirement: {
type: 'opt-in',
how: `To use Proxy's user management system, go to LND config and select Bitcoin Proxy under Bitcoin config.`,
},
config: null,
},
},
},
installed: {
manifest: {
...Mock.MockManifestLnd,
version: '0.11.0',
},
'last-backup': null,
status: {
configured: true,
main: {
status: PackageMainStatus.Stopped,
},
'dependency-config-errors': {
'btc-rpc-proxy': 'This is a config unsatisfied error',
},
},
'interface-addresses': {
rpc: {
'tor-address': 'lnd-rpc-address.onion',
'lan-address': 'lnd-rpc-address.local',
},
grpc: {
'tor-address': 'lnd-grpc-address.onion',
'lan-address': 'lnd-grpc-address.local',
},
},
'system-pointers': [],
'current-dependents': {},
'current-dependencies': {
bitcoind: {
pointers: [],
'health-checks': [],
},
'btc-rpc-proxy': {
pointers: [],
'health-checks': [],
},
},
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',
icon: 'assets/img/service-icons/bitcoind.svg',
},
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
},
},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
},
},
}

View File

@@ -0,0 +1,50 @@
import { Injectable, NgZone } from '@angular/core'
import { ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { Router } from '@angular/router'
import { StorageService } from './storage.service'
export enum AuthState {
UNVERIFIED,
VERIFIED,
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$ = new ReplaySubject<AuthState>(1)
readonly isVerified$ = this.authState$.pipe(
map(state => state === AuthState.VERIFIED),
distinctUntilChanged(),
)
constructor(
private readonly storage: StorageService,
private readonly zone: NgZone,
private readonly router: Router,
) {}
init(): void {
const loggedIn = this.storage.get(this.LOGGED_IN_KEY)
if (loggedIn) {
this.setVerified()
} else {
this.setUnverified()
}
}
setVerified(): void {
this.storage.set(this.LOGGED_IN_KEY, true)
this.authState$.next(AuthState.VERIFIED)
}
setUnverified(): void {
this.authState$.next(AuthState.UNVERIFIED)
this.storage.clear()
this.zone.run(() => {
this.router.navigate(['/login'], { replaceUrl: true })
})
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@angular/core'
import { ReplaySubject, Subject } from 'rxjs'
import { WorkspaceConfig } from '../../../../shared/src/types/workspace-config'
import { StorageService } from './storage.service'
const SHOW_DEV_TOOLS = 'SHOW_DEV_TOOLS'
const SHOW_DISK_REPAIR = 'SHOW_DISK_REPAIR'
const WIDGET_DRAWER = 'WIDGET_DRAWER'
const { enableWidgets } =
require('../../../../../config.json') as WorkspaceConfig
export type WidgetDrawer = {
open: boolean
width: 400 | 600
}
@Injectable({
providedIn: 'root',
})
export class ClientStorageService {
readonly showDevTools$ = new ReplaySubject<boolean>(1)
readonly showDiskRepair$ = new ReplaySubject<boolean>(1)
readonly widgetDrawer$ = new ReplaySubject<WidgetDrawer>(1)
constructor(private readonly storage: StorageService) {}
init() {
this.showDevTools$.next(!!this.storage.get(SHOW_DEV_TOOLS))
this.showDiskRepair$.next(!!this.storage.get(SHOW_DISK_REPAIR))
this.widgetDrawer$.next(
enableWidgets
? this.storage.get(WIDGET_DRAWER) || {
open: true,
width: 600,
}
: {
open: false,
width: 600,
},
)
}
toggleShowDevTools(): boolean {
const newVal = !this.storage.get(SHOW_DEV_TOOLS)
this.storage.set(SHOW_DEV_TOOLS, newVal)
this.showDevTools$.next(newVal)
return newVal
}
toggleShowDiskRepair(): boolean {
const newVal = !this.storage.get(SHOW_DISK_REPAIR)
this.storage.set(SHOW_DISK_REPAIR, newVal)
this.showDiskRepair$.next(newVal)
return newVal
}
updateWidgetDrawer(drawer: WidgetDrawer) {
this.widgetDrawer$.next(drawer)
this.storage.set(WIDGET_DRAWER, drawer)
}
}

View File

@@ -0,0 +1,145 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import {
InterfaceDef,
PackageDataEntry,
PackageMainStatus,
PackageState,
} from 'src/app/services/patch-db/data-model'
const {
gitHash,
useMocks,
ui: { api, marketplace, mocks },
} = require('../../../../../config.json') as WorkspaceConfig
@Injectable({
providedIn: 'root',
})
export class ConfigService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
hostname = this.document.location.hostname
// includes port
host = this.document.location.host
// includes ":" (e.g. "http:")
protocol = this.document.location.protocol
version = require('../../../../../package.json').version as string
useMocks = useMocks
mocks = mocks
gitHash = gitHash
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = (window as any)['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean {
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
}
isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
isTorHttp(): boolean {
return this.isTor() && !this.isHttps()
}
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
isLaunchable(
state: PackageState,
status: PackageMainStatus,
interfaces: Record<string, InterfaceDef>,
): boolean {
return (
state === PackageState.Installed &&
status === PackageMainStatus.Running &&
hasUi(interfaces)
)
}
launchableURL(pkg: PackageDataEntry): string {
if (!this.isTor() && hasLocalUi(pkg.manifest.interfaces)) {
return `https://${lanUiAddress(pkg)}`
} else {
return `http://${torUiAddress(pkg)}`
}
}
getHost(): string {
return this.host
}
private isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost'
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
}
export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean {
const int = getUiInterfaceValue(interfaces)
return !!int?.['tor-config']
}
export function hasLocalUi(interfaces: Record<string, InterfaceDef>): boolean {
const int = getUiInterfaceValue(interfaces)
return !!int?.['lan-config']
}
export function torUiAddress({
manifest,
installed,
}: PackageDataEntry): string {
const key = getUiInterfaceKey(manifest.interfaces)
return installed ? installed['interface-addresses'][key]['tor-address'] : ''
}
export function lanUiAddress({
manifest,
installed,
}: PackageDataEntry): string {
const key = getUiInterfaceKey(manifest.interfaces)
return installed ? installed['interface-addresses'][key]['lan-address'] : ''
}
export function hasUi(interfaces: Record<string, InterfaceDef>): boolean {
return hasTorUi(interfaces) || hasLocalUi(interfaces)
}
export function removeProtocol(str: string): string {
if (str.startsWith('http://')) return str.slice(7)
if (str.startsWith('https://')) return str.slice(8)
return str
}
export function removePort(str: string): string {
return str.split(':')[0]
}
export function getUiInterfaceKey(
interfaces: Record<string, InterfaceDef>,
): string {
return Object.keys(interfaces).find(key => interfaces[key].ui) || ''
}
export function getUiInterfaceValue(
interfaces: Record<string, InterfaceDef>,
): InterfaceDef | null {
return Object.values(interfaces).find(i => i.ui) || null
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core'
import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
@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()),
]).pipe(
map(([network, websocket]) => network && websocket),
distinctUntilChanged(),
)
}

View File

@@ -0,0 +1,214 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
HealthCheckResult,
HealthResult,
InstalledPackageDataEntry,
PackageMainStatus,
} from './patch-db/data-model'
import * as deepEqual from 'fast-deep-equal'
export type AllDependencyErrors = Record<string, PkgDependencyErrors>
export type PkgDependencyErrors = Record<string, DependencyError | null>
@Injectable({
providedIn: 'root',
})
export class DepErrorService {
readonly depErrors$ = this.patch.watch$('package-data').pipe(
map(pkgs =>
Object.keys(pkgs)
.map(id => ({
id,
depth: dependencyDepth(pkgs, id),
}))
.sort((a, b) => (b.depth > a.depth ? -1 : 1))
.reduce(
(errors, { id }): AllDependencyErrors => ({
...errors,
[id]: this.getDepErrors(pkgs, id, errors),
}),
{} as AllDependencyErrors,
),
),
distinctUntilChanged(deepEqual),
shareReplay({ bufferSize: 1, refCount: true }),
)
constructor(
private readonly emver: Emver,
private readonly patch: PatchDB<DataModel>,
) {}
getPkgDepErrors$(pkgId: string) {
return this.depErrors$.pipe(
map(depErrors => depErrors[pkgId]),
distinctUntilChanged(deepEqual),
)
}
private getDepErrors(
pkgs: DataModel['package-data'],
pkgId: string,
outerErrors: AllDependencyErrors,
): PkgDependencyErrors {
const pkgInstalled = pkgs[pkgId].installed
if (!pkgInstalled) return {}
return currentDeps(pkgs, pkgId).reduce(
(innerErrors, depId): PkgDependencyErrors => ({
...innerErrors,
[depId]: this.getDepError(pkgs, pkgInstalled, depId, outerErrors),
}),
{} as PkgDependencyErrors,
)
}
private getDepError(
pkgs: DataModel['package-data'],
pkgInstalled: InstalledPackageDataEntry,
depId: string,
outerErrors: AllDependencyErrors,
): DependencyError | null {
const depInstalled = pkgs[depId]?.installed
// not installed
if (!depInstalled) {
return {
type: DependencyErrorType.NotInstalled,
}
}
const pkgManifest = pkgInstalled.manifest
const depManifest = depInstalled.manifest
// incorrect version
if (
!this.emver.satisfies(
depManifest.version,
pkgManifest.dependencies[depId].version,
)
) {
return {
type: DependencyErrorType.IncorrectVersion,
expected: pkgManifest.dependencies[depId].version,
received: depManifest.version,
}
}
// invalid config
if (
Object.values(pkgInstalled.status['dependency-config-errors']).some(
err => !!err,
)
) {
return {
type: DependencyErrorType.ConfigUnsatisfied,
}
}
const depStatus = depInstalled.status.main.status
// not running
if (
depStatus !== PackageMainStatus.Running &&
depStatus !== PackageMainStatus.Starting
) {
return {
type: DependencyErrorType.NotRunning,
}
}
// health check failure
if (depStatus === PackageMainStatus.Running) {
for (let id of pkgInstalled['current-dependencies'][depId][
'health-checks'
]) {
if (
depInstalled.status.main.health[id]?.result !== HealthResult.Success
) {
return {
type: DependencyErrorType.HealthChecksFailed,
}
}
}
}
// transitive
const transitiveError = currentDeps(pkgs, depId).some(transitiveId =>
Object.values(outerErrors[transitiveId]).some(err => !!err),
)
if (transitiveError) {
return {
type: DependencyErrorType.Transitive,
}
}
return null
}
}
function currentDeps(pkgs: DataModel['package-data'], id: string): string[] {
return Object.keys(
pkgs[id]?.installed?.['current-dependencies'] || {},
).filter(depId => depId !== id)
}
function dependencyDepth(
pkgs: DataModel['package-data'],
id: string,
depth = 0,
): number {
return currentDeps(pkgs, id).reduce(
(prev, depId) => dependencyDepth(pkgs, depId, prev + 1),
depth,
)
}
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'notInstalled',
NotRunning = 'notRunning',
IncorrectVersion = 'incorrectVersion',
ConfigUnsatisfied = 'configUnsatisfied',
HealthChecksFailed = 'healthChecksFailed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
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
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}

View File

@@ -0,0 +1,60 @@
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 { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import { getServerInfo } from 'src/app/util/get-server-info'
import { DataModel } from './patch-db/data-model'
@Injectable({
providedIn: 'root',
})
export class EOSService {
eos?: MarketplaceEOS
updateAvailable$ = new BehaviorSubject<boolean>(false)
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
map(status => !!status['update-progress'] || status.updated),
distinctUntilChanged(),
)
readonly backingUp$ = this.patch
.watch$('server-info', 'status-info', 'backup-progress')
.pipe(
map(obj => !!obj),
distinctUntilChanged(),
)
readonly updatingOrBackingUp$ = combineLatest([
this.updating$,
this.backingUp$,
]).pipe(
map(([updating, backingUp]) => {
return updating || backingUp
}),
)
readonly showUpdate$ = combineLatest([
this.updateAvailable$,
this.updating$,
]).pipe(
map(([available, updating]) => {
return available && !updating
}),
)
constructor(
private readonly api: ApiService,
private readonly emver: Emver,
private readonly patch: PatchDB<DataModel>,
) {}
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
this.updateAvailable$.next(updateAvailable)
}
}

View File

@@ -0,0 +1,563 @@
import { Injectable } from '@angular/core'
import {
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
ValidatorFn,
Validators,
} from '@angular/forms'
import {
ConfigSpec,
isValueSpecListOf,
ListValueSpecNumber,
ListValueSpecObject,
ListValueSpecOf,
ListValueSpecString,
ListValueSpecUnion,
UniqueBy,
ValueSpec,
ValueSpecEnum,
ValueSpecList,
ValueSpecNumber,
ValueSpecObject,
ValueSpecString,
ValueSpecUnion,
} from 'src/app/pkg-config/config-types'
import { getDefaultString, Range } from '../pkg-config/config-utilities'
const Mustache = require('mustache')
@Injectable({
providedIn: 'root',
})
export class FormService {
constructor(private readonly formBuilder: UntypedFormBuilder) {}
createForm(
spec: ConfigSpec,
current: { [key: string]: any } = {},
): UntypedFormGroup {
return this.getFormGroup(spec, [], current)
}
getUnionObject(
spec: ValueSpecUnion | ListValueSpecUnion,
selection: string,
current?: { [key: string]: any } | null,
): UntypedFormGroup {
const { variants, tag } = spec
const { name, description, warning, 'variant-names': variantNames } = tag
const enumSpec: ValueSpecEnum = {
type: 'enum',
name,
description,
warning,
default: selection,
values: Object.keys(variants),
'value-names': variantNames,
}
return this.getFormGroup(
{ [spec.tag.id]: enumSpec, ...spec.variants[selection] },
[],
current,
)
}
getListItem(spec: ValueSpecList, entry: any) {
const listItemValidators = getListItemValidators(spec)
if (isValueSpecListOf(spec, 'string')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'number')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'enum')) {
return this.formBuilder.control(entry)
} else if (isValueSpecListOf(spec, 'object')) {
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
} else if (isValueSpecListOf(spec, 'union')) {
return this.getUnionObject(spec.spec, spec.spec.default, entry)
}
}
private getFormGroup(
config: ConfigSpec,
validators: ValidatorFn[] = [],
current?: { [key: string]: any } | null,
): UntypedFormGroup {
let group: Record<
string,
UntypedFormGroup | UntypedFormArray | UntypedFormControl
> = {}
Object.entries(config).map(([key, spec]) => {
if (spec.type === 'pointer') return
group[key] = this.getFormEntry(spec, current ? current[key] : undefined)
})
return this.formBuilder.group(group, { validators })
}
private getFormEntry(
spec: ValueSpec,
currentValue?: any,
): UntypedFormGroup | UntypedFormArray | UntypedFormControl {
let validators: ValidatorFn[]
let value: any
switch (spec.type) {
case 'string':
validators = stringValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default ? getDefaultString(spec.default) : null
}
return this.formBuilder.control(value, validators)
case 'number':
validators = numberValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, validators)
case 'object':
return this.getFormGroup(spec.spec, [], currentValue)
case 'list':
validators = listValidators(spec)
const mapped = (
Array.isArray(currentValue) ? currentValue : (spec.default as any[])
).map(entry => {
return this.getListItem(spec, entry)
})
return this.formBuilder.array(mapped, validators)
case 'union':
const currentSelection = currentValue?.[spec.tag.id]
const isValid = !!spec.variants[currentSelection]
return this.getUnionObject(
spec,
isValid ? currentSelection : spec.default,
isValid ? currentValue : undefined,
)
case 'boolean':
case 'enum':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value)
default:
return this.formBuilder.control(null)
}
}
}
function getListItemValidators(spec: ValueSpecList) {
if (isValueSpecListOf(spec, 'string')) {
return stringValidators(spec.spec)
} else if (isValueSpecListOf(spec, 'number')) {
return numberValidators(spec.spec)
}
}
function stringValidators(
spec: ValueSpecString | ListValueSpecString,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (!(spec as ValueSpecString).nullable) {
validators.push(Validators.required)
}
if (spec.pattern) {
validators.push(Validators.pattern(spec.pattern))
}
return validators
}
function numberValidators(
spec: ValueSpecNumber | ListValueSpecNumber,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(isNumber())
if (!(spec as ValueSpecNumber).nullable) {
validators.push(Validators.required)
}
if (spec.integral) {
validators.push(isInteger())
}
validators.push(numberInRange(spec.range))
return validators
}
function listValidators(spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.range))
validators.push(listItemIssue())
if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec))
}
return validators
}
export function numberInRange(stringRange: string): ValidatorFn {
return control => {
const value = control.value
if (!value) return null
try {
Range.from(stringRange).checkIncludes(value)
return null
} catch (e: any) {
return { numberNotInRange: { value: `Number must be ${e.message}` } }
}
}
}
export function isNumber(): ValidatorFn {
return control =>
!control.value || control.value == Number(control.value)
? null
: { notNumber: { value: control.value } }
}
export function isInteger(): ValidatorFn {
return control =>
!control.value || control.value == Math.trunc(control.value)
? null
: { numberNotInteger: { value: control.value } }
}
export function listInRange(stringRange: string): ValidatorFn {
return control => {
try {
Range.from(stringRange).checkIncludes(control.value.length)
return null
} catch (e: any) {
return { listNotInRange: { value: `List must be ${e.message}` } }
}
}
}
export function listItemIssue(): ValidatorFn {
return parentControl => {
const { controls } = parentControl as UntypedFormArray
const problemChild = controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: { value: 'Invalid entries' } }
} else {
return null
}
}
}
export function listUnique(spec: ValueSpecList): ValidatorFn {
return control => {
const list = control.value
for (let idx = 0; idx < list.length; idx++) {
for (let idx2 = idx + 1; idx2 < list.length; idx2++) {
if (listItemEquals(spec, list[idx], list[idx2])) {
let display1: string
let display2: string
let uniqueMessage = isObjectOrUnion(spec.spec)
? uniqueByMessageWrapper(
spec.spec['unique-by'],
spec.spec,
list[idx],
)
: ''
if (isObjectOrUnion(spec.spec) && spec.spec['display-as']) {
display1 = `"${(Mustache as any).render(
spec.spec['display-as'],
list[idx],
)}"`
display2 = `"${(Mustache as any).render(
spec.spec['display-as'],
list[idx2],
)}"`
} else {
display1 = `Entry ${idx + 1}`
display2 = `Entry ${idx2 + 1}`
}
return {
listNotUnique: {
value: `${display1} and ${display2} are not unique.${uniqueMessage}`,
},
}
}
}
}
return null
}
}
function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean {
// TODO: fix types
switch (spec.subtype) {
case 'string':
case 'number':
case 'enum':
return val1 == val2
case 'object':
const obj: ListValueSpecObject = spec.spec as any
return listObjEquals(obj['unique-by'], obj, val1, val2)
case 'union':
const union: ListValueSpecUnion = spec.spec as any
return unionEquals(union['unique-by'], union, val1, val2)
default:
return false
}
}
function itemEquals(spec: ValueSpec, val1: any, val2: any): boolean {
switch (spec.type) {
case 'string':
case 'number':
case 'boolean':
case 'enum':
return val1 == val2
case 'object':
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types
return objEquals(
(spec as any)['unique-by'],
spec as ValueSpecObject,
val1,
val2,
)
case 'union':
// TODO: 'unique-by' does not exist on ValueSpecUnion, fix types
return unionEquals(
(spec as any)['unique-by'],
spec as ValueSpecUnion,
val1,
val2,
)
case 'list':
if (val1.length !== val2.length) {
return false
}
for (let idx = 0; idx < val1.length; idx++) {
if (listItemEquals(spec, val1[idx], val2[idx])) {
return false
}
}
return true
default:
return false
}
}
function listObjEquals(
uniqueBy: UniqueBy,
spec: ListValueSpecObject,
val1: any,
val2: any,
): boolean {
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
return itemEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (listObjEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!listObjEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
return false
}
function objEquals(
uniqueBy: UniqueBy,
spec: ValueSpecObject,
val1: any,
val2: any,
): boolean {
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
// TODO: fix types
return itemEquals((spec as any)[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (objEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!objEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
return false
}
function unionEquals(
uniqueBy: UniqueBy,
spec: ValueSpecUnion | ListValueSpecUnion,
val1: any,
val2: any,
): boolean {
const tagId = spec.tag.id
const variant = spec.variants[val1[tagId]]
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
if (uniqueBy === tagId) {
return val1[tagId] === val2[tagId]
} else {
return itemEquals(variant[uniqueBy], val1[uniqueBy], val2[uniqueBy])
}
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (unionEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!unionEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
return false
}
function uniqueByMessageWrapper(
uniqueBy: UniqueBy,
spec: ListValueSpecObject | ListValueSpecUnion,
obj: Record<string, string>,
) {
let configSpec: ConfigSpec
if (isUnion(spec)) {
const tagId = spec.tag.id
configSpec = {
[tagId]: { name: spec.tag.name } as ValueSpec,
...spec.variants[obj[tagId]],
}
} else {
configSpec = spec.spec
}
const message = uniqueByMessage(uniqueBy, configSpec)
if (message) {
return ' Must be unique by: ' + message
}
}
function uniqueByMessage(
uniqueBy: UniqueBy,
configSpec: ConfigSpec,
outermost = true,
): string {
let joinFunc
const subSpecs: string[] = []
if (uniqueBy === null) {
return ''
} else if (typeof uniqueBy === 'string') {
return configSpec[uniqueBy]
? (configSpec[uniqueBy] as ValueSpecObject).name
: uniqueBy
} else if ('any' in uniqueBy) {
joinFunc = ' OR '
for (let subSpec of uniqueBy.any) {
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
}
} else if ('all' in uniqueBy) {
joinFunc = ' AND '
for (let subSpec of uniqueBy.all) {
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
}
}
const ret = subSpecs.filter(Boolean).join(joinFunc)
return outermost || subSpecs.filter(ss => ss).length === 1
? ret
: '(' + ret + ')'
}
function isObjectOrUnion(
spec: ListValueSpecOf<any>,
): spec is ListValueSpecObject | ListValueSpecUnion {
// only lists of objects and unions have unique-by
return 'unique-by' in spec
}
function isUnion(spec: any): spec is ListValueSpecUnion {
// only unions have tag
return !!spec.tag
}
export function convertValuesRecursive(
configSpec: ConfigSpec,
group: UntypedFormGroup,
) {
Object.entries(configSpec).forEach(([key, valueSpec]) => {
const control = group.get(key)
if (!control) return
if (valueSpec.type === 'number') {
control.setValue(
control.value || control.value === 0 ? Number(control.value) : null,
)
} else if (valueSpec.type === 'string') {
if (!control.value) control.setValue(null)
} else if (valueSpec.type === 'object') {
convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup)
} else if (valueSpec.type === 'union') {
const formGr = group.get(key) as UntypedFormGroup
const spec = valueSpec.variants[formGr.controls[valueSpec.tag.id].value]
convertValuesRecursive(spec, formGr)
} else if (valueSpec.type === 'list') {
const formArr = group.get(key) as UntypedFormArray
const { controls } = formArr
if (valueSpec.subtype === 'number') {
controls.forEach(control => {
control.setValue(control.value ? Number(control.value) : null)
})
} else if (valueSpec.subtype === 'string') {
controls.forEach(control => {
if (!control.value) control.setValue(null)
})
} else if (valueSpec.subtype === 'object') {
controls.forEach(formGroup => {
const objectSpec = valueSpec.spec as ListValueSpecObject
convertValuesRecursive(objectSpec.spec, formGroup as UntypedFormGroup)
})
} else if (valueSpec.subtype === 'union') {
controls.forEach(formGroup => {
const unionSpec = valueSpec.spec as ListValueSpecUnion
const spec =
unionSpec.variants[
(formGroup as UntypedFormGroup).controls[unionSpec.tag.id].value
]
convertValuesRecursive(spec, formGroup as UntypedFormGroup)
})
}
}
})
}

View File

@@ -0,0 +1,321 @@
import { Injectable } from '@angular/core'
import {
MarketplacePkg,
AbstractMarketplaceService,
StoreData,
Marketplace,
StoreInfo,
StoreIdentity,
} from '@start9labs/marketplace'
import {
BehaviorSubject,
combineLatest,
distinctUntilKeyChanged,
from,
mergeMap,
Observable,
of,
scan,
} from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import {
catchError,
filter,
map,
pairwise,
shareReplay,
startWith,
switchMap,
take,
tap,
} from 'rxjs/operators'
import { ConfigService } from './config.service'
import { sameUrl } from '@start9labs/shared'
import { ClientStorageService } from './client-storage.service'
@Injectable()
export class MarketplaceService implements AbstractMarketplaceService {
private readonly knownHosts$: Observable<StoreIdentity[]> = this.patch
.watch$('ui', 'marketplace', 'known-hosts')
.pipe(
map(hosts => {
const { start9, community } = this.config.marketplace
let arr = [
toStoreIdentity(start9, hosts[start9]),
toStoreIdentity(community, hosts[community]),
]
return arr.concat(
Object.entries(hosts)
.filter(([url, _]) => ![start9, community].includes(url as any))
.map(([url, store]) => toStoreIdentity(url, store)),
)
}),
)
private readonly filteredKnownHosts$: Observable<StoreIdentity[]> =
combineLatest([
this.clientStorageService.showDevTools$,
this.knownHosts$,
]).pipe(
map(([devMode, knownHosts]) =>
devMode
? knownHosts
: knownHosts.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
private readonly selectedHost$: Observable<StoreIdentity> = this.patch
.watch$('ui', 'marketplace')
.pipe(
distinctUntilKeyChanged('selected-url'),
map(({ 'selected-url': url, 'known-hosts': hosts }) =>
toStoreIdentity(url, hosts[url]),
),
shareReplay({ bufferSize: 1, refCount: true }),
)
private readonly marketplace$ = this.knownHosts$.pipe(
startWith<StoreIdentity[]>([]),
pairwise(),
mergeMap(([prev, curr]) =>
curr.filter(c => !prev.find(p => sameUrl(c.url, p.url))),
),
mergeMap(({ url, name }) =>
this.fetchStore$(url).pipe(
tap(data => {
if (data?.info) this.updateStoreName(url, name, data.info.name)
}),
map<StoreData | null, [string, StoreData | null]>(data => {
return [url, data]
}),
startWith<[string, StoreData | null]>([url, null]),
),
),
scan<[string, StoreData | null], Record<string, StoreData | null>>(
(requests, [url, store]) => {
requests[url] = store
return requests
},
{},
),
shareReplay({ bufferSize: 1, refCount: true }),
)
private readonly filteredMarketplace$ = combineLatest([
this.clientStorageService.showDevTools$,
this.marketplace$,
]).pipe(
map(([devMode, marketplace]) =>
Object.entries(marketplace).reduce(
(filtered, [url, store]) =>
!devMode && (url.includes('alpha') || url.includes('beta'))
? filtered
: {
[url]: store,
...filtered,
},
{} as Marketplace,
),
),
)
private readonly selectedStore$: Observable<StoreData> =
this.selectedHost$.pipe(
switchMap(({ url }) =>
this.marketplace$.pipe(
map(m => m[url]),
filter(Boolean),
take(1),
),
),
)
private readonly requestErrors$ = new BehaviorSubject<string[]>([])
constructor(
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly clientStorageService: ClientStorageService,
) {}
getKnownHosts$(filtered = false): Observable<StoreIdentity[]> {
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
return filtered ? this.filteredKnownHosts$ : this.knownHosts$
}
getSelectedHost$(): Observable<StoreIdentity> {
return this.selectedHost$
}
getMarketplace$(filtered = false): Observable<Marketplace> {
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
return filtered ? this.filteredMarketplace$ : this.marketplace$
}
getSelectedStore$(): Observable<StoreData> {
return this.selectedStore$
}
getPackage$(
id: string,
version: string,
optionalUrl?: string,
): Observable<MarketplacePkg> {
return this.patch.watch$('ui', 'marketplace').pipe(
switchMap(uiMarketplace => {
const url = optionalUrl || uiMarketplace['selected-url']
if (version !== '*' || !uiMarketplace['known-hosts'][url]) {
return this.fetchPackage$(id, version, url)
}
return this.marketplace$.pipe(
map(m => m[url]),
filter(Boolean),
take(1),
map(
store =>
store.packages.find(p => p.manifest.id === id) ||
({} as MarketplacePkg),
),
)
}),
)
}
// UI only
readonly updateErrors: Record<string, string> = {}
readonly updateQueue: Record<string, boolean> = {}
getRequestErrors$(): Observable<string[]> {
return this.requestErrors$
}
async installPackage(
id: string,
version: string,
url: string,
): Promise<void> {
const params: RR.InstallPackageReq = {
id,
'version-spec': `=${version}`,
'marketplace-url': url,
}
await this.api.installPackage(params)
}
fetchInfo$(url: string): Observable<StoreInfo> {
return this.patch.watch$('server-info').pipe(
take(1),
switchMap(serverInfo => {
const qp: RR.GetMarketplaceInfoReq = { 'server-id': serverInfo.id }
return this.api.marketplaceProxy<RR.GetMarketplaceInfoRes>(
'/package/v0/info',
qp,
url,
)
}),
)
}
fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>> {
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<Record<string, string>>(
`/package/v0/release-notes/${id}`,
{},
url || m.url,
),
)
}),
)
}
fetchStatic$(id: string, type: string, url?: string): Observable<string> {
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<string>(
`/package/v0/${type}/${id}`,
{},
url || m.url,
),
)
}),
)
}
private fetchStore$(url: string): Observable<StoreData | null> {
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
map(([info, packages]) => ({ info, packages })),
catchError(e => {
console.error(e)
this.requestErrors$.next(this.requestErrors$.value.concat(url))
return of(null)
}),
)
}
private fetchPackages$(
url: string,
params: Omit<RR.GetMarketplacePackagesReq, 'page' | 'per-page'> = {},
): Observable<MarketplacePkg[]> {
const qp: RR.GetMarketplacePackagesReq = {
...params,
page: 1,
'per-page': 100,
}
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
return from(
this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
'/package/v0/index',
qp,
url,
),
)
}
private fetchPackage$(
id: string,
version: string,
url: string,
): Observable<MarketplacePkg> {
return this.fetchPackages$(url, { ids: [{ id, version }] }).pipe(
map(pkgs => pkgs[0] || {}),
)
}
private async updateStoreName(
url: string,
oldName: string | undefined,
newName: string,
): Promise<void> {
if (oldName !== newName) {
this.api.setDbValue<string>(
['marketplace', 'known-hosts', url, 'name'],
newName,
)
}
}
}
function toStoreIdentity(url: string, uiStore: UIStore): StoreIdentity {
return {
url,
...uiStore,
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { DependentInfo } from 'src/app/types/dependent-info'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
@Injectable({
providedIn: 'root',
})
export class ModalService {
constructor(private readonly modalCtrl: ModalController) {}
async presentModalConfig(componentProps: ComponentProps): Promise<void> {
const modal = await this.modalCtrl.create({
component: AppConfigPage,
componentProps,
})
await modal.present()
}
}
interface ComponentProps {
pkgId: string
dependentInfo?: DependentInfo
}

View File

@@ -0,0 +1,69 @@
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 { PatchDB } from 'patch-db-client'
import { DataModel, UIData } 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'
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'
// 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(
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['ack-welcome'])
}),
share(),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly eosService: EOSService,
private readonly config: ConfigService,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly connectionService: ConnectionService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private checkForUpdates(): void {
this.eosService.loadEos()
this.marketplaceService.getMarketplace$().pipe(take(1)).subscribe()
}
private async showEosWelcome(ackVersion: string): Promise<void> {
if (this.config.skipStartupAlerts || ackVersion === this.config.version) {
return
}
const modal = await this.modalCtrl.create({
component: OSWelcomePage,
presentingElement: await this.modalCtrl.getTop(),
backdropDismiss: false,
})
modal.onWillDismiss().then(() => {
this.embassyApi
.setDbValue<string>(['ack-welcome'], this.config.version)
.catch()
})
await modal.present()
}
}

View File

@@ -0,0 +1,376 @@
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { Url } from '@start9labs/shared'
import { MarketplaceManifest } from '@start9labs/marketplace'
import { BasicInfo } from 'src/app/pages/developer-routes/developer-menu/form-info'
export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
ui: UIData
}
export interface UIData {
name: string | null
'ack-welcome': string // eOS emver
marketplace: UIMarketplaceData
dev: DevData
gaming: {
snake: {
'high-score': number
}
}
'ack-instructions': Record<string, boolean>
theme: string
widgets: readonly Widget[]
}
export interface Widget {
id: string
meta: {
name: string
width: number
height: number
mobileWidth: number
mobileHeight: number
}
url?: string
settings?: string
}
export interface UIMarketplaceData {
'selected-url': string
'known-hosts': {
'https://registry.start9.com/': UIStore
'https://community-registry.start9.com/': UIStore
[url: string]: UIStore
}
}
export interface UIStore {
name?: string
}
export interface DevData {
[id: string]: DevProjectData
}
export interface DevProjectData {
name: string
instructions: string
config: string
'basic-info'?: BasicInfo
}
export interface ServerInfo {
id: string
version: string
'last-backup': string | null
'lan-address': Url
'tor-address': Url
'ip-info': IpInfo
'last-wifi-region': string | null
'unread-notification-count': number
'status-info': ServerStatusInfo
'eos-version-compat': string
'password-hash': string
hostname: string
pubkey: string
'ca-fingerprint': string
'ntp-synced': boolean
zram: boolean
platform: string
}
export interface IpInfo {
[iface: string]: {
ipv4: string | null
ipv6: string | null
}
}
export interface ServerStatusInfo {
'backup-progress': null | {
[packageId: string]: {
complete: boolean
}
}
updated: boolean
'update-progress': { size: number | null; downloaded: number } | null
restarting: boolean
'shutting-down': boolean
}
export enum ServerStatus {
Running = 'running',
Updated = 'updated',
BackingUp = 'backing-up',
}
export interface PackageDataEntry {
state: PackageState
'static-files': {
license: Url
instructions: Url
icon: Url
}
manifest: Manifest
installed?: InstalledPackageDataEntry // exists when: installed, updating
'install-progress'?: InstallProgress // exists when: installing, updating
}
export enum PackageState {
Installing = 'installing',
Installed = 'installed',
Updating = 'updating',
Removing = 'removing',
Restoring = 'restoring',
}
export interface InstalledPackageDataEntry {
status: Status
manifest: Manifest
'last-backup': string | null
'system-pointers': any[]
'current-dependents': { [id: string]: CurrentDependencyInfo }
'current-dependencies': { [id: string]: CurrentDependencyInfo }
'dependency-info': {
[id: string]: {
title: string
icon: Url
}
}
'interface-addresses': {
[id: string]: { 'tor-address': string; 'lan-address': string }
}
'marketplace-url': string | null
'developer-key': string
}
export interface CurrentDependencyInfo {
pointers: any[]
'health-checks': string[] // array of health check IDs
}
export interface Manifest extends MarketplaceManifest<DependencyConfig | null> {
assets: {
license: string // filename
instructions: string // filename
icon: string // filename
docker_images: string // filename
assets: string // path to assets folder
scripts: string // path to scripts folder
}
main: ActionImpl
'health-checks': Record<
string,
ActionImpl & { name: string; 'success-message': string | null }
>
config: ConfigActions | null
volumes: Record<string, Volume>
'min-os-version': string
interfaces: Record<string, InterfaceDef>
backup: BackupActions
migrations: Migrations | null
actions: Record<string, Action>
}
export interface DependencyConfig {
check: ActionImpl
'auto-configure': ActionImpl
}
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
'sigterm-timeout': string | null
}
export enum DockerIoFormat {
Json = 'json',
Yaml = 'yaml',
Cbor = 'cbor',
Toml = 'toml',
}
export interface ConfigActions {
get: ActionImpl | null
set: ActionImpl | null
}
export type Volume = VolumeData
export interface VolumeData {
type: VolumeType.Data
readonly: boolean
}
export interface VolumeAssets {
type: VolumeType.Assets
}
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 VolumeBackup {
type: VolumeType.Backup
readonly: boolean
}
export enum VolumeType {
Data = 'data',
Assets = 'assets',
Pointer = 'pointer',
Certificate = 'certificate',
Backup = 'backup',
}
export interface InterfaceDef {
name: string
description: string
'tor-config': TorConfig | null
'lan-config': LanConfig | null
ui: boolean
protocols: string[]
}
export interface TorConfig {
'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 | null
}
export interface Status {
configured: boolean
main: MainStatus
'dependency-config-errors': { [id: string]: string | null }
}
export type MainStatus =
| MainStatusStopped
| MainStatusStopping
| MainStatusStarting
| MainStatusRunning
| MainStatusBackingUp
| MainStatusRestarting
export interface MainStatusStopped {
status: PackageMainStatus.Stopped
}
export interface MainStatusStopping {
status: PackageMainStatus.Stopping
}
export interface MainStatusStarting {
status: PackageMainStatus.Starting
restarting: boolean
}
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 MainStatusRestarting {
status: PackageMainStatus.Restarting
}
export enum PackageMainStatus {
Starting = 'starting',
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
Restarting = 'restarting',
}
export type HealthCheckResult =
| HealthCheckResultStarting
| HealthCheckResultLoading
| HealthCheckResultDisabled
| HealthCheckResultSuccess
| HealthCheckResultFailure
export enum HealthResult {
Starting = 'starting',
Loading = 'loading',
Disabled = 'disabled',
Success = 'success',
Failure = 'failure',
}
export interface HealthCheckResultStarting {
result: HealthResult.Starting
}
export interface HealthCheckResultDisabled {
result: HealthResult.Disabled
}
export interface HealthCheckResultSuccess {
result: HealthResult.Success
}
export interface HealthCheckResultLoading {
result: HealthResult.Loading
message: string
}
export interface HealthCheckResultFailure {
result: HealthResult.Failure
error: string
}
export interface InstallProgress {
readonly size: number | null
readonly downloaded: number
readonly 'download-complete': boolean
readonly validated: number
readonly 'validation-complete': boolean
readonly unpacked: number
readonly 'unpack-complete': boolean
}

View File

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

View File

@@ -0,0 +1,57 @@
import { InjectionToken, Injector } from '@angular/core'
import {
bufferTime,
catchError,
filter,
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 { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service'
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
'',
)
export function sourceFactory(
injector: Injector,
): Observable<Update<DataModel>[]> {
// 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 websocket$ = api.openPatchWebsocket$().pipe(
bufferTime(250),
filter(updates => !!updates.length),
catchError((_, watch$) => {
connectionService.websocketConnected$.next(false)
return interval(timeout).pipe(
switchMap(() =>
from(api.echo({ message: 'ping', timeout })).pipe(
catchError(() => EMPTY),
),
),
take(1),
switchMap(() => watch$),
)
}),
tap(() => connectionService.websocketConnected$.next(true)),
)
return authService.isVerified$.pipe(
switchMap(verified => (verified ? websocket$ : EMPTY)),
)
})
}

View File

@@ -0,0 +1,20 @@
import { PatchDB } from 'patch-db-client'
import { Injector, NgModule } from '@angular/core'
import { PATCH_SOURCE, sourceFactory } from './patch-db.factory'
// This module is purely for providers organization purposes
@NgModule({
providers: [
{
provide: PATCH_SOURCE,
deps: [Injector],
useFactory: sourceFactory,
},
{
provide: PatchDB,
deps: [PATCH_SOURCE],
useClass: PatchDB,
},
],
})
export class PatchDbModule {}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
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>
private readonly stream$ = this.authService.isVerified$.pipe(
tap(verified =>
verified ? this.patch.start(this.bootstrapper) : 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,188 @@
import { isEmptyObject } from '@start9labs/shared'
import {
MainStatusStarting,
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import { PkgDependencyErrors } from './dep-error.service'
export interface PackageStatus {
primary: PrimaryStatus
dependency: DependencyStatus | null
health: HealthStatus | null
}
export function renderPkgStatus(
pkg: PackageDataEntry,
depErrors: PkgDependencyErrors,
): PackageStatus {
let primary: PrimaryStatus
let dependency: DependencyStatus | null = null
let health: HealthStatus | null = null
if (pkg.state === PackageState.Installed && pkg.installed) {
primary = getPrimaryStatus(pkg.installed.status)
dependency = getDependencyStatus(depErrors)
health = getHealthStatus(
pkg.installed.status,
!isEmptyObject(pkg.manifest['health-checks']),
)
} else {
primary = pkg.state as string as PrimaryStatus
}
return { primary, dependency, health }
}
function getPrimaryStatus(status: Status): PrimaryStatus {
if (!status.configured) {
return PrimaryStatus.NeedsConfig
} else if ((status.main as MainStatusStarting).restarting) {
return PrimaryStatus.Restarting
} else {
return status.main.status as any as PrimaryStatus
}
}
function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus {
return Object.values(depErrors).some(err => !!err)
? DependencyStatus.Warning
: DependencyStatus.Satisfied
}
function getHealthStatus(
status: Status,
hasHealthChecks: boolean,
): HealthStatus | null {
if (status.main.status !== PackageMainStatus.Running || !status.main.health) {
return null
}
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {
return HealthStatus.Failure
}
if (!values.length && hasHealthChecks) {
return HealthStatus.Waiting
}
if (values.some(h => h.result === 'loading')) {
return HealthStatus.Loading
}
if (values.some(h => !h.result || h.result === 'starting')) {
return HealthStatus.Starting
}
return HealthStatus.Healthy
}
export interface StatusRendering {
display: string
color: string
showDots?: boolean
}
export enum PrimaryStatus {
// state
Installing = 'installing',
Updating = 'updating',
Removing = 'removing',
Restoring = 'restoring',
// status
Starting = 'starting',
Running = 'running',
Stopping = 'stopping',
Restarting = 'restarting',
Stopped = 'stopped',
BackingUp = 'backing-up',
// config
NeedsConfig = 'needs-config',
}
export enum DependencyStatus {
Warning = 'warning',
Satisfied = 'satisfied',
}
export enum HealthStatus {
Failure = 'failure',
Waiting = 'waiting',
Starting = 'starting',
Loading = 'loading',
Healthy = 'healthy',
}
export const PrimaryRendering: Record<string, StatusRendering> = {
[PrimaryStatus.Installing]: {
display: 'Installing',
color: 'primary',
showDots: true,
},
[PrimaryStatus.Updating]: {
display: 'Updating',
color: 'primary',
showDots: true,
},
[PrimaryStatus.Removing]: {
display: 'Removing',
color: 'danger',
showDots: true,
},
[PrimaryStatus.Restoring]: {
display: 'Restoring',
color: 'primary',
showDots: true,
},
[PrimaryStatus.Stopping]: {
display: 'Stopping',
color: 'dark-shade',
showDots: true,
},
[PrimaryStatus.Restarting]: {
display: 'Restarting',
color: 'tertiary',
showDots: true,
},
[PrimaryStatus.Stopped]: {
display: 'Stopped',
color: 'dark-shade',
showDots: false,
},
[PrimaryStatus.BackingUp]: {
display: 'Backing Up',
color: 'primary',
showDots: true,
},
[PrimaryStatus.Starting]: {
display: 'Starting',
color: 'primary',
showDots: true,
},
[PrimaryStatus.Running]: {
display: 'Running',
color: 'success',
showDots: false,
},
[PrimaryStatus.NeedsConfig]: {
display: 'Needs Config',
color: 'warning',
showDots: false,
},
}
export const DependencyRendering: Record<string, StatusRendering> = {
[DependencyStatus.Warning]: { display: 'Issue', color: 'warning' },
[DependencyStatus.Satisfied]: { display: 'Satisfied', color: 'success' },
}
export const HealthRendering: Record<string, StatusRendering> = {
[HealthStatus.Failure]: { display: 'Failure', color: 'danger' },
[HealthStatus.Starting]: { display: 'Starting', color: 'primary' },
[HealthStatus.Loading]: { display: 'Loading', color: 'primary' },
[HealthStatus.Healthy]: { display: 'Healthy', color: 'success' },
}

View File

@@ -0,0 +1,9 @@
import { BehaviorSubject } from 'rxjs'
import { Injectable } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class SplitPaneTracker {
readonly sidebarOpen$ = new BehaviorSubject<boolean>(false)
}

View File

@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
const PREFIX = '_embassystorage/_embassykv/'
@Injectable({
providedIn: 'root',
})
export class StorageService {
private readonly storage = this.document.defaultView!.localStorage
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
get<T>(key: string): T {
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))
}
set<T>(key: string, value: T) {
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))
}
}

View File

@@ -0,0 +1,37 @@
import { Inject, Injectable } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject } from 'rxjs'
import { ApiService } from './api/embassy-api.service'
import { DataModel } from './patch-db/data-model'
import { filter, take } from 'rxjs/operators'
@Injectable({
providedIn: 'root',
})
export class ThemeSwitcherService extends BehaviorSubject<string> {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly embassyApi: ApiService,
@Inject(WINDOW) private readonly windowRef: Window,
) {
super('Dark')
this.patch
.watch$('ui', 'theme')
.pipe(take(1), filter(Boolean))
.subscribe(theme => {
this.updateTheme(theme)
})
}
override next(theme: string): void {
this.embassyApi.setDbValue(['theme'], theme)
this.updateTheme(theme)
}
private updateTheme(theme: string): void {
this.windowRef.document.body.setAttribute('data-theme', theme)
super.next(theme)
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@angular/core'
import { map, shareReplay, startWith, switchMap } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { DataModel } from './patch-db/data-model'
import { ApiService } from './api/embassy-api.service'
import { combineLatest, interval, of } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class TimeService {
private readonly time$ = of({}).pipe(
switchMap(() => this.apiService.getSystemTime({})),
switchMap(({ now, uptime }) => {
const current = new Date(now).valueOf()
return interval(1000).pipe(
map(index => {
const incremented = index + 1
return {
now: current + 1000 * incremented,
uptime: uptime + incremented,
}
}),
startWith({
now: current,
uptime,
}),
)
}),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly now$ = combineLatest([
this.time$,
this.patch.watch$('server-info', 'ntp-synced'),
]).pipe(
map(([time, synced]) => ({
value: time.now,
synced,
})),
)
readonly uptime$ = this.time$.pipe(
map(({ uptime }) => {
const days = Math.floor(uptime / (24 * 60 * 60))
const daysSec = uptime % (24 * 60 * 60)
const hours = Math.floor(daysSec / (60 * 60))
const hoursSec = uptime % (60 * 60)
const minutes = Math.floor(hoursSec / 60)
const seconds = uptime % 60
return { days, hours, minutes, seconds }
}),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly apiService: ApiService,
) {}
}

View File

@@ -0,0 +1,18 @@
import { Inject, Injectable } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class UiLauncherService {
constructor(
@Inject(WINDOW) private readonly windowRef: Window,
private readonly config: ConfigService,
) {}
launch(pkg: PackageDataEntry): void {
this.windowRef.open(this.config.launchableURL(pkg), '_blank', 'noreferrer')
}
}