Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Aiden McClelland
2023-11-13 14:59:16 -07:00
1115 changed files with 6871 additions and 1851 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,605 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
HealthCheckResult,
} from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
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 = {
guid: string
metrics: 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 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 EnableWifiReq = { enable: boolean } // wifi.enable
export type EnableWifiRes = null
export type ConnectWifiReq = { ssid: string } // wifi.connect
export type ConnectWifiRes = null
export type DeleteWifiReq = { ssid: string } // wifi.delete
export type DeleteWifiRes = null
// email
export type ConfigureEmailReq = typeof customSmtp.validator._TYPE // email.configure
export type ConfigureEmailRes = null
export type TestEmailReq = ConfigureEmailReq & { to: string } // email.test
export type TestEmailRes = 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 = {
'unknown-disks': UnknownDisk[]
saved: BackupTarget[]
}
export type AddCifsBackupTargetReq = {
name: string
path: string
hostname: string
username: string
password?: string
} // backup.target.cifs.add
export type AddCloudBackupTargetReq = {
name: string
path: string
provider: CloudProvider
[params: string]: any
} // backup.target.cloud.add
export type AddDiskBackupTargetReq = {
logicalname: string
name: string
path: string
} // backup.target.disk.add
export type AddBackupTargetRes = BackupTarget
export type UpdateCifsBackupTargetReq = AddCifsBackupTargetReq & {
id: string
} // backup.target.cifs.update
export type UpdateCloudBackupTargetReq = AddCloudBackupTargetReq & {
id: string
} // backup.target.cloud.update
export type UpdateDiskBackupTargetReq = Omit<
AddDiskBackupTargetReq,
'logicalname'
> & {
id: string
} // backup.target.disk.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type RemoveBackupTargetReq = { id: string } // backup.target.remove
export type RemoveBackupTargetRes = null
export type GetBackupJobsReq = {} // backup.job.list
export type GetBackupJobsRes = BackupJob[]
export type CreateBackupJobReq = {
name: string
'target-id': string
cron: string
'package-ids': string[]
now: boolean
} // backup.job.create
export type CreateBackupJobRes = BackupJob
export type UpdateBackupJobReq = Omit<CreateBackupJobReq, 'now'> & {
id: string
} // backup.job.update
export type UpdateBackupJobRes = CreateBackupJobRes
export type DeleteBackupJobReq = { id: string } // backup.job.delete
export type DeleteBackupJobRes = null
export type GetBackupRunsReq = {} // backup.runs
export type GetBackupRunsRes = BackupRun[]
export type DeleteBackupRunsReq = { ids: string[] } // backup.runs.delete
export type DeleteBackupRunsRes = null
export type GetBackupInfoReq = { 'target-id': string; password: string } // backup.target.info
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = { 'target-id': string; 'package-ids': string[] } // backup.create
export type CreateBackupRes = null
// package
export type GetPackageCredentialsReq = { id: string } // package.credentials
export type GetPackageCredentialsRes = Record<string, string>
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 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: InputSpec; 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
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: InputSpec
}
export type SideloadPackageReq = {
manifest: Manifest
icon: string // base64
size: number // bytes
}
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 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 RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget
export type BackupTarget = RemoteBackupTarget | DiskBackupTarget
export type BackupTargetType = 'disk' | 'cifs' | 'cloud'
export interface UnknownDisk {
logicalname: string
vendor: string | null
model: string | null
label: string | null
capacity: number
used: number | null
}
export interface BaseBackupTarget {
id: string
type: BackupTargetType
name: string
mountable: boolean
path: string
'embassy-os': StartOSDiskInfo | null
}
export interface DiskBackupTarget extends UnknownDisk, BaseBackupTarget {
type: 'disk'
}
export interface CifsBackupTarget extends BaseBackupTarget {
type: 'cifs'
hostname: string
username: string
}
export interface CloudBackupTarget extends BaseBackupTarget {
type: 'cloud'
provider: 'dropbox' | 'google-drive'
}
export interface BackupRun {
id: string
'started-at': string
'completed-at': string
'package-ids': string[]
job: BackupJob
report: BackupReport
}
export interface BackupJob {
id: string
name: string
target: BackupTarget
cron: string // '* * * * * *' https://cloud.google.com/scheduler/docs/configuring/cron-job-schedules
'package-ids': 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 CloudProvider = 'dropbox' | 'google-drive'
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,276 @@
import { Observable } from 'rxjs'
import { Update } from 'patch-db-client'
import { RR, BackupTargetType, Metrics } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log, SetupStatus } 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<void>
abstract uploadFile(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 openMetricsWebsocket$(
config: WebSocketSubjectConfig<Metrics>,
): Observable<Metrics>
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 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 enableWifi(params: RR.EnableWifiReq): Promise<RR.EnableWifiRes>
abstract getWifi(
params: RR.GetWifiReq,
timeout: number,
): Promise<RR.GetWifiRes>
abstract addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes>
abstract connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
abstract deleteWifi(params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
// email
abstract testEmail(params: RR.TestEmailReq): Promise<RR.TestEmailRes>
abstract configureEmail(
params: RR.ConfigureEmailReq,
): Promise<RR.ConfigureEmailRes>
// 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(
type: BackupTargetType,
params:
| RR.AddCifsBackupTargetReq
| RR.AddCloudBackupTargetReq
| RR.AddDiskBackupTargetReq,
): Promise<RR.AddBackupTargetRes>
abstract updateBackupTarget(
type: BackupTargetType,
params:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes>
abstract removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes>
abstract getBackupJobs(
params: RR.GetBackupJobsReq,
): Promise<RR.GetBackupJobsRes>
abstract createBackupJob(
params: RR.CreateBackupJobReq,
): Promise<RR.CreateBackupJobRes>
abstract updateBackupJob(
params: RR.UpdateBackupJobReq,
): Promise<RR.UpdateBackupJobRes>
abstract deleteBackupJob(
params: RR.DeleteBackupJobReq,
): Promise<RR.DeleteBackupJobRes>
abstract getBackupRuns(
params: RR.GetBackupRunsReq,
): Promise<RR.GetBackupRunsRes>
abstract deleteBackupRuns(
params: RR.DeleteBackupRunsReq,
): Promise<RR.DeleteBackupRunsRes>
abstract getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes>
abstract createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
// package
abstract getPackageCredentials(
params: RR.GetPackageCredentialsReq,
): Promise<RR.GetPackageCredentialsRes>
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>
abstract getSetupStatus(): Promise<SetupStatus | null>
abstract followLogs(): Promise<string>
}

View File

@@ -0,0 +1,532 @@
import { Inject, Injectable } from '@angular/core'
import {
HttpOptions,
HttpService,
isRpcError,
Log,
Method,
RpcError,
RPCOptions,
SetupStatus,
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { BackupTargetType, Metrics, RR } from './api.types'
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()
// @ts-ignore
this.document.defaultView.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<void> {
return this.httpRequest({
method: Method.POST,
body,
url: `/rest/rpc/${guid}`,
responseType: 'text',
})
}
async uploadFile(body: Blob): Promise<string> {
return this.httpRequest({
method: Method.POST,
body,
url: `/rest/upload`,
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)
}
async followLogs(): Promise<string> {
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return this.openWebsocket(config)
}
openMetricsWebsocket$(
config: WebSocketSubjectConfig<Metrics>,
): Observable<Metrics> {
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 enableWifi(params: RR.EnableWifiReq): Promise<RR.EnableWifiRes> {
return this.rpcRequest({ method: 'wifi.enable', params })
}
async getWifi(
params: RR.GetWifiReq,
timeout?: number,
): Promise<RR.GetWifiRes> {
return this.rpcRequest({ method: 'wifi.get', params, timeout })
}
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 })
}
// email
async testEmail(params: RR.TestEmailReq): Promise<RR.TestEmailRes> {
return this.rpcRequest({ method: 'email.test', params })
}
async configureEmail(
params: RR.ConfigureEmailReq,
): Promise<RR.ConfigureEmailRes> {
return this.rpcRequest({ method: 'email.configure', 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(
type: BackupTargetType,
params: RR.AddCifsBackupTargetReq | RR.AddCloudBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/')
return this.rpcRequest({ method: `backup.target.${type}.add`, params })
}
async updateBackupTarget(
type: BackupTargetType,
params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
return this.rpcRequest({ method: `backup.target.${type}.update`, params })
}
async removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> {
return this.rpcRequest({ method: 'backup.target.remove', params })
}
async getBackupJobs(
params: RR.GetBackupJobsReq,
): Promise<RR.GetBackupJobsRes> {
return this.rpcRequest({ method: 'backup.job.list', params })
}
async createBackupJob(
params: RR.CreateBackupJobReq,
): Promise<RR.CreateBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.create', params })
}
async updateBackupJob(
params: RR.UpdateBackupJobReq,
): Promise<RR.UpdateBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.update', params })
}
async deleteBackupJob(
params: RR.DeleteBackupJobReq,
): Promise<RR.DeleteBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.delete', params })
}
async getBackupRuns(
params: RR.GetBackupRunsReq,
): Promise<RR.GetBackupRunsRes> {
return this.rpcRequest({ method: 'backup.runs.list', params })
}
async deleteBackupRuns(
params: RR.DeleteBackupRunsReq,
): Promise<RR.DeleteBackupRunsRes> {
return this.rpcRequest({ method: 'backup.runs.delete', 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 getPackageCredentials(
params: RR.GetPackageCredentialsReq,
): Promise<RR.GetPackageCredentialsRes> {
return this.rpcRequest({ method: 'package.credentials', params })
}
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 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,
})
}
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
method: 'setup.status',
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,85 @@
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BUILT_IN_WIDGETS } from 'src/app/apps/ui/pages/widgets/built-in/widgets'
import { Mock } from './api.fixures'
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',
},
},
},
gaming: {
snake: {
'high-score': 0,
},
},
'ack-instructions': {},
},
'server-info': {
id: 'abcdefgh',
version: '0.3.5',
country: 'us',
'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,
'wifi-enabled': false,
'unread-notification-count': 4,
'eos-version-compat': '>=0.3.0 <=0.3.0.1',
'status-info': {
'current-backup': 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,
smtp: {
server: '',
port: 587,
from: '',
login: '',
password: '',
tls: true,
},
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
platform: 'x86_64-nonfree',
},
'package-data': {
bitcoind: Mock.bitcoind,
lnd: Mock.lnd,
},
}

View File

@@ -0,0 +1,51 @@
import { Injectable, NgZone } from '@angular/core'
import { distinctUntilChanged, map, ReplaySubject } from 'rxjs'
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 {
if (this.storage.get(this.LOGGED_IN_KEY)) {
this.setVerified()
} else {
this.setUnverified(true)
}
}
setVerified(): void {
this.storage.set(this.LOGGED_IN_KEY, true)
this.authState$.next(AuthState.VERIFIED)
}
setUnverified(skipNavigation = false): void {
this.authState$.next(AuthState.UNVERIFIED)
this.storage.clear()
if (!skipNavigation) {
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,84 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import { InstalledPackageInfo } 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()
}
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 hasUi(
addressInfo: InstalledPackageInfo['address-info'],
): boolean {
return !!Object.values(addressInfo).find(a => a.ui)
}
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]
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core'
import {
combineLatest,
distinctUntilChanged,
map,
startWith,
fromEvent,
merge,
ReplaySubject,
} from 'rxjs'
@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,19 @@
import { Injectable } from '@angular/core'
import { AbstractTuiValueTransformer, TuiDay } from '@taiga-ui/cdk'
type From = TuiDay | null
type To = string | null
@Injectable()
export class DateTransformerService extends AbstractTuiValueTransformer<
From,
To
> {
fromControlValue(controlValue: To): From {
return controlValue ? TuiDay.jsonParse(controlValue) : null
}
toControlValue(componentValue: From): To {
return componentValue?.toJSON() || null
}
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core'
import { AbstractTuiValueTransformer, TuiDay, TuiTime } from '@taiga-ui/cdk'
type From = [TuiDay | null, TuiTime | null] | null
type To = string | null
@Injectable()
export class DatetimeTransformerService extends AbstractTuiValueTransformer<
From,
To
> {
fromControlValue(controlValue: To): From {
if (!controlValue) {
return null
}
const day = TuiDay.jsonParse(controlValue.slice(0, 10))
const time =
controlValue.length === 16
? TuiTime.fromString(controlValue.slice(-5))
: null
return [day, time]
}
toControlValue(componentValue: From): To {
if (!componentValue) {
return null
}
const [day, time] = componentValue
return day?.toJSON() + (time ? `T${time.toString()}` : '')
}
}

View File

@@ -0,0 +1,219 @@
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,
HealthResult,
InstalledPackageInfo,
PackageMainStatus,
} from './patch-db/data-model'
import * as deepEqual from 'fast-deep-equal'
import { Manifest } from '@start9labs/marketplace'
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,
pkgs[pkgId].manifest,
depId,
outerErrors,
),
}),
{} as PkgDependencyErrors,
)
}
private getDepError(
pkgs: DataModel['package-data'],
pkgInstalled: InstalledPackageInfo,
pkgManifest: Manifest,
depId: string,
outerErrors: AllDependencyErrors,
): DependencyError | null {
const depInstalled = pkgs[depId]?.installed
const depManifest = pkgs[depId]?.manifest
// not installed
if (!depInstalled) {
return {
type: DependencyErrorType.NotInstalled,
}
}
// 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,59 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, distinctUntilChanged, map, combineLatest } from 'rxjs'
import { MarketplaceEOS } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
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', 'current-backup')
.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,41 @@
import { inject, Injectable, Injector, Type } from '@angular/core'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiDialogFormService, TuiPromptData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
export const PROMPT: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Unsaved Changes',
data: {
content: 'You have unsaved changes. Are you sure you want to leave?',
yes: 'Leave',
no: 'Cancel',
},
}
@Injectable({ providedIn: 'root' })
export class FormDialogService {
private readonly dialogs = inject(TuiDialogService)
private readonly formService = new TuiDialogFormService(this.dialogs)
private readonly prompt = this.formService.withPrompt(PROMPT)
private readonly injector = Injector.create({
parent: inject(Injector),
providers: [
{
provide: TuiDialogFormService,
useValue: this.formService,
},
],
})
open<T>(component: Type<any>, options: Partial<TuiDialogOptions<T>> = {}) {
this.dialogs
.open(new PolymorpheusComponent(component, this.injector), {
closeable: this.prompt,
dismissible: this.prompt,
...options,
})
.subscribe({
complete: () => this.formService.markAsPristine(),
})
}
}

View File

@@ -0,0 +1,670 @@
import { Injectable } from '@angular/core'
import {
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
ValidatorFn,
Validators,
} from '@angular/forms'
import { getDefaultString } from '../util/config-utilities'
import {
InputSpec,
ListValueSpecNumber,
ListValueSpecObject,
ListValueSpecOf,
ListValueSpecText,
UniqueBy,
ValueSpec,
ValueSpecSelect,
ValueSpecMultiselect,
ValueSpecFile,
ValueSpecList,
ValueSpecNumber,
ValueSpecObject,
ValueSpecText,
ValueSpecUnion,
ValueSpecTextarea,
ValueSpecColor,
ValueSpecDatetime,
unionSelectKey,
unionValueKey,
isValueSpecListOf,
} from '@start9labs/start-sdk/lib/config/configTypes'
const Mustache = require('mustache')
@Injectable({
providedIn: 'root',
})
export class FormService {
constructor(private readonly formBuilder: UntypedFormBuilder) {}
createForm(
spec: InputSpec,
current: Record<string, any> = {},
): UntypedFormGroup {
return this.getFormGroup(spec, [], current)
}
getUnionSelectSpec(
spec: ValueSpecUnion,
selection: string | null,
): ValueSpecSelect {
return {
...spec,
type: 'select',
default: selection,
values: Object.fromEntries(
Object.entries(spec.variants).map(([key, { name }]) => [key, name]),
),
}
}
getUnionObject(
spec: ValueSpecUnion,
selection: string | null,
): UntypedFormGroup {
const group = this.getFormGroup({
[unionSelectKey]: this.getUnionSelectSpec(spec, selection),
})
group.setControl(
unionValueKey,
this.getFormGroup(selection ? spec.variants[selection].spec : {}),
)
return group
}
getListItem(spec: ValueSpecList, entry?: any) {
const listItemValidators = getListItemValidators(spec)
if (isValueSpecListOf(spec, 'text')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'number')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'object')) {
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
}
}
getFormGroup(
config: InputSpec,
validators: ValidatorFn[] = [],
current?: Record<string, any> | null,
): UntypedFormGroup {
let group: Record<
string,
UntypedFormGroup | UntypedFormArray | UntypedFormControl
> = {}
Object.entries(config).map(([key, spec]) => {
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 value: any
switch (spec.type) {
case 'text':
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default ? getDefaultString(spec.default) : null
}
return this.formBuilder.control(value, stringValidators(spec))
case 'textarea':
value = currentValue || null
return this.formBuilder.control(value, textareaValidators(spec))
case 'number':
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, numberValidators(spec))
case 'color':
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, colorValidators(spec))
case 'datetime':
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, datetimeValidators(spec))
case 'object':
return this.getFormGroup(spec.spec, [], currentValue)
case 'list':
const mapped = (
Array.isArray(currentValue) ? currentValue : (spec.default as any[])
).map(entry => {
return this.getListItem(spec, entry)
})
return this.formBuilder.array(mapped, listValidators(spec))
case 'file':
return this.formBuilder.control(
currentValue || null,
fileValidators(spec),
)
case 'union':
const currentSelection = currentValue?.[unionSelectKey]
const isValid = !!spec.variants[currentSelection]
return this.getUnionObject(
spec,
isValid ? currentSelection : spec.default,
)
case 'toggle':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value)
case 'select':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value, selectValidators(spec))
case 'multiselect':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value, multiselectValidators(spec))
default:
return this.formBuilder.control(null)
}
}
}
function getListItemValidators(spec: ValueSpecList) {
if (isValueSpecListOf(spec, 'text')) {
return stringValidators(spec.spec)
} else if (isValueSpecListOf(spec, 'number')) {
return numberValidators(spec.spec)
}
}
function stringValidators(
spec: ValueSpecText | ListValueSpecText,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
if ((spec as ValueSpecText).required) {
validators.push(Validators.required)
}
validators.push(textLengthInRange(spec.minLength, spec.maxLength))
if (spec.patterns.length) {
spec.patterns.forEach(p => validators.push(Validators.pattern(p.regex)))
}
return validators
}
function textareaValidators(spec: ValueSpecTextarea): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (spec.required) {
validators.push(Validators.required)
}
validators.push(textLengthInRange(spec.minLength, spec.maxLength))
return validators
}
function colorValidators({ required }: ValueSpecColor): ValidatorFn[] {
const validators: ValidatorFn[] = [Validators.pattern(/^#[0-9a-f]{6}$/i)]
if (required) {
validators.push(Validators.required)
}
return validators
}
function datetimeValidators({
required,
min,
max,
}: ValueSpecDatetime): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (required) {
validators.push(Validators.required)
}
if (min) {
validators.push(datetimeMin(min))
}
if (max) {
validators.push(datetimeMax(max))
}
return validators
}
function numberValidators(
spec: ValueSpecNumber | ListValueSpecNumber,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(isNumber())
if ((spec as ValueSpecNumber).required) {
validators.push(Validators.required)
}
if (spec.integer) {
validators.push(isInteger())
}
validators.push(numberInRange(spec.min, spec.max))
return validators
}
function selectValidators(spec: ValueSpecSelect): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (spec.required) {
validators.push(Validators.required)
}
return validators
}
function multiselectValidators(spec: ValueSpecMultiselect): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.minLength, spec.maxLength))
return validators
}
function listValidators(spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.minLength, spec.maxLength))
validators.push(listItemIssue())
return validators
}
function fileValidators(spec: ValueSpecFile): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (spec.required) {
validators.push(Validators.required)
}
return validators
}
export function numberInRange(
min: number | null,
max: number | null,
): ValidatorFn {
return control => {
const value = control.value
if (typeof value !== 'number') return null
if (min && value < min)
return {
numberNotInRange: `Number must be greater than or equal to ${min}`,
}
if (max && value > max)
return { numberNotInRange: `Number must be less than or equal to ${max}` }
return null
}
}
export function isNumber(): ValidatorFn {
return ({ value }) =>
!value || value == Number(value) ? null : { notNumber: 'Must be a number' }
}
export function isInteger(): ValidatorFn {
return ({ value }) =>
!value || value == Math.trunc(value)
? null
: { numberNotInteger: 'Must be an integer' }
}
export function listInRange(
minLength: number | null,
maxLength: number | null,
): ValidatorFn {
return control => {
const length = control.value.length
if (minLength && length < minLength)
return {
listNotInRange: `List must contain at least ${minLength} entries`,
}
if (maxLength && length > maxLength)
return {
listNotInRange: `List cannot contain more than ${maxLength} entries`,
}
return null
}
}
export function datetimeMin(min: string): ValidatorFn {
return ({ value }) => {
if (!value) return null
const date = new Date(value.length === 5 ? `2000-01-01T${value}` : value)
const minDate = new Date(min.length === 5 ? `2000-01-01T${min}` : min)
return date < minDate ? { datetimeMin: `Minimum is ${min}` } : null
}
}
export function datetimeMax(max: string): ValidatorFn {
return ({ value }) => {
if (!value) return null
const date = new Date(value.length === 5 ? `2000-01-01T${value}` : value)
const maxDate = new Date(max.length === 5 ? `2000-01-01T${max}` : max)
return date > maxDate ? { datetimeMin: `Maximum is ${max}` } : null
}
}
export function textLengthInRange(
minLength: number | null,
maxLength: number | null,
): ValidatorFn {
return control => {
const value = control.value
if (value === null || value === undefined) return null
const length = value.length
if (minLength && length < minLength)
return { listNotInRange: `Must be at least ${minLength} characters` }
if (maxLength && length > maxLength)
return { listNotInRange: `Cannot be great than ${maxLength} characters` }
return null
}
}
export function listItemIssue(): ValidatorFn {
return parentControl => {
const { controls } = parentControl as UntypedFormArray
const problemChild = controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: '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])) {
const objSpec = spec.spec
let display1: string
let display2: string
let uniqueMessage = isObject(objSpec)
? uniqueByMessageWrapper(objSpec.uniqueBy, objSpec, list[idx])
: ''
if (isObject(objSpec) && objSpec.displayAs) {
display1 = `"${(Mustache as any).render(
objSpec.displayAs,
list[idx],
)}"`
display2 = `"${(Mustache as any).render(
objSpec.displayAs,
list[idx2],
)}"`
} else {
display1 = `Entry ${idx + 1}`
display2 = `Entry ${idx2 + 1}`
}
return {
listNotUnique: `${display1} and ${display2} are not unique.${uniqueMessage}`,
}
}
}
}
return null
}
}
function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean {
// TODO: fix types
switch (spec.spec.type) {
case 'text':
case 'number':
return val1 == val2
case 'object':
const obj = spec.spec
return listObjEquals(obj.uniqueBy, obj, val1, val2)
default:
return false
}
}
function itemEquals(spec: ValueSpec, val1: any, val2: any): boolean {
switch (spec.type) {
case 'text':
case 'textarea':
case 'number':
case 'toggle':
case 'select':
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) {
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) {
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,
val1: any,
val2: any,
): boolean {
const variantSpec = spec.variants[val1[unionSelectKey]].spec
if (!uniqueBy) {
return false
} else if (typeof uniqueBy === 'string') {
if (uniqueBy === unionSelectKey) {
return val1[unionSelectKey] === val2[unionSelectKey]
} else {
return itemEquals(variantSpec[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,
obj: Record<string, string>,
) {
let configSpec = spec.spec
const message = uniqueByMessage(uniqueBy, configSpec)
if (message) {
return ' Must be unique by: ' + message
}
}
function uniqueByMessage(
uniqueBy: UniqueBy,
configSpec: InputSpec,
outermost = true,
): string {
let joinFunc
const subSpecs: string[] = []
if (!uniqueBy) {
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 isObject(spec: ListValueSpecOf<any>): spec is ListValueSpecObject {
// only lists of objects have uniqueBy
return 'uniqueBy' in spec
}
export function convertValuesRecursive(
configSpec: InputSpec,
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 === 'text' || valueSpec.type === 'textarea') {
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[unionSelectKey].value].spec
convertValuesRecursive(spec, formGr)
} else if (valueSpec.type === 'list') {
const formArr = group.get(key) as UntypedFormArray
const { controls } = formArr
if (valueSpec.spec.type === 'number') {
controls.forEach(control => {
control.setValue(control.value ? Number(control.value) : null)
})
} else if (valueSpec.spec.type === 'text') {
controls.forEach(control => {
if (!control.value) control.setValue(null)
})
} else if (valueSpec.spec.type === 'object') {
controls.forEach(formGroup => {
const objectSpec = valueSpec.spec as ListValueSpecObject
convertValuesRecursive(objectSpec.spec, formGroup as UntypedFormGroup)
})
}
}
})
}

View File

@@ -0,0 +1,319 @@
import { Injectable } from '@angular/core'
import { sameUrl } from '@start9labs/shared'
import {
MarketplacePkg,
AbstractMarketplaceService,
StoreData,
Marketplace,
StoreInfo,
StoreIdentity,
} from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
import {
BehaviorSubject,
combineLatest,
distinctUntilKeyChanged,
from,
mergeMap,
Observable,
of,
scan,
catchError,
filter,
map,
pairwise,
shareReplay,
startWith,
switchMap,
take,
tap,
} 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 { ConfigService } from './config.service'
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,68 @@
import { Inject, Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { filter, share, switchMap, take, tap, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
import { OSWelcomePage } from '../common/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,300 @@
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { Url } from '@start9labs/shared'
import { Manifest } from '@start9labs/marketplace'
import { BackupJob } from '../api/api.types'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
ui: UIData
}
export interface UIData {
name: string | null
'ack-welcome': string // emver
marketplace: UIMarketplaceData
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 ServerInfo {
id: string
version: string
country: string
'last-backup': string | null
'lan-address': Url
'tor-address': Url
'ip-info': IpInfo
'last-wifi-region': string | null
'wifi-enabled': boolean
'unread-notification-count': number
'status-info': ServerStatusInfo
'eos-version-compat': string
hostname: string
pubkey: string
'ca-fingerprint': string
'ntp-synced': boolean
zram: boolean
smtp: typeof customSmtp.validator._TYPE
'password-hash': string
platform: string
}
export interface IpInfo {
[iface: string]: {
ipv4: string | null
ipv6: string | null
}
}
export interface ServerStatusInfo {
'current-backup': null | {
job: BackupJob
'backup-progress': {
[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
manifest: Manifest
icon: string
installed?: InstalledPackageInfo // when: installed
actions?: Record<string, Action> // when: installed
'install-progress'?: InstallProgress // when: installing, updating, restoring
}
// export type PackageDataEntry =
// | PackageDataEntryInstalled
// | PackageDataEntryNeedsUpdate
// | PackageDataEntryRemoving
// | PackageDataEntryRestoring
// | PackageDataEntryUpdating
// | PackageDataEntryInstalling
// export type PackageDataEntryBase = {
// manifest: Manifest
// icon: Url
// }
// export interface PackageDataEntryInstalled extends PackageDataEntryBase {
// state: PackageState.Installed
// installed: InstalledPackageInfo
// actions: Record<string, Action>
// }
// export interface PackageDataEntryNeedsUpdate extends PackageDataEntryBase {
// state: PackageState.NeedsUpdate
// }
// export interface PackageDataEntryRemoving extends PackageDataEntryBase {
// state: PackageState.Removing
// }
// export interface PackageDataEntryRestoring extends PackageDataEntryBase {
// state: PackageState.Restoring
// 'install-progress': InstallProgress
// }
// export interface PackageDataEntryUpdating extends PackageDataEntryBase {
// state: PackageState.Updating
// 'install-progress': InstallProgress
// }
// export interface PackageDataEntryInstalling extends PackageDataEntryBase {
// state: PackageState.Installing
// 'install-progress': InstallProgress
// }
export enum PackageState {
Installing = 'installing',
Installed = 'installed',
Updating = 'updating',
Removing = 'removing',
Restoring = 'restoring',
NeedsUpdate = 'needs-update',
}
export interface InstalledPackageInfo {
status: Status
'last-backup': string | null
'installed-at': string
'current-dependencies': Record<string, CurrentDependencyInfo>
'current-dependents': Record<string, CurrentDependencyInfo>
'dependency-info': Record<string, { title: string; icon: Url }>
'address-info': Record<string, AddressInfo>
'marketplace-url': string | null
'developer-key': string
'has-config': boolean
}
export interface CurrentDependencyInfo {
'health-checks': string[] // array of health check IDs
}
export interface AddressInfo {
name: string
description: string
addresses: Url[]
ui: boolean
}
export interface Action {
name: string
description: string
warning: string | null
disabled: string | null
'input-spec': InputSpec | null
group: string | null
}
export interface Status {
configured: boolean
main: MainStatus
'dependency-config-errors': { [id: string]: string | null }
}
export type MainStatus =
| MainStatusStopped
| MainStatusStopping
| MainStatusStarting
| MainStatusRunning
| MainStatusBackingUp
| MainStatusRestarting
| MainStatusConfiguring
export interface MainStatusStopped {
status: PackageMainStatus.Stopped
}
export interface MainStatusStopping {
status: PackageMainStatus.Stopping
}
export interface MainStatusStarting {
status: PackageMainStatus.Starting
}
export interface MainStatusRunning {
status: PackageMainStatus.Running
started: string // UTC date string
health: { [id: string]: HealthCheckResult }
}
export interface MainStatusBackingUp {
status: PackageMainStatus.BackingUp
}
export interface MainStatusRestarting {
status: PackageMainStatus.Restarting
}
export interface MainStatusConfiguring {
status: PackageMainStatus.Configuring
}
export enum PackageMainStatus {
Starting = 'starting',
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
Restarting = 'restarting',
Configuring = 'configuring',
}
export type HealthCheckResult = { name: string } & (
| 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
reason: string
}
export interface HealthCheckResultSuccess {
result: HealthResult.Success
message: string
}
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,61 @@
import { InjectionToken, Injector } from '@angular/core'
import { Update } from 'patch-db-client'
import {
bufferTime,
catchError,
filter,
switchMap,
take,
tap,
defer,
EMPTY,
from,
interval,
Observable,
} from 'rxjs'
import { DataModel } from './data-model'
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,27 @@
import { Injectable } from '@angular/core'
import { tap, Observable } from 'rxjs'
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,174 @@
import {
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import { PkgDependencyErrors } from './dep-error.service'
export interface PackageStatus {
primary: PrimaryStatus | PackageState | PackageMainStatus
dependency: DependencyStatus | null
health: HealthStatus | null
}
export function renderPkgStatus(
pkg: PackageDataEntry,
depErrors: PkgDependencyErrors,
): PackageStatus {
let primary: PrimaryStatus | PackageState | PackageMainStatus
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)
} else {
primary = pkg.state
}
return { primary, dependency, health }
}
function getPrimaryStatus(status: Status): PrimaryStatus | PackageMainStatus {
if (!status.configured) {
return PrimaryStatus.NeedsConfig
} else {
return status.main.status
}
}
function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus {
return Object.values(depErrors).some(err => !!err)
? DependencyStatus.Warning
: DependencyStatus.Satisfied
}
function getHealthStatus(status: Status): HealthStatus | null {
if (status.main.status !== PackageMainStatus.Running) {
return null
}
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {
return HealthStatus.Failure
}
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,36 @@
import { Inject, Injectable } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { PatchDB } from 'patch-db-client'
import { filter, take, BehaviorSubject } from 'rxjs'
import { ApiService } from './api/embassy-api.service'
import { DataModel } from './patch-db/data-model'
@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')
}
}