rename frontend to web

This commit is contained in:
Matt Hill
2023-11-13 15:59:16 -07:00
parent 09303ab2fb
commit 862ca375ee
869 changed files with 0 additions and 66 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,674 @@
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,
DomainInfo,
NetworkStrategy,
OsOutboundProxy,
ServiceOutboundProxy,
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 SetServerClearnetAddressReq = { domainInfo: DomainInfo | null } // server.set-clearnet
export type SetServerClearnetAddressRes = null
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
export type SetOsOutboundProxyReq = {
proxy: OsOutboundProxy
} // server.proxy.set-outbound
export type SetOsOutboundProxyRes = 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
// network
export type AddProxyReq = {
name: string
config: string
} // net.proxy.add
export type AddProxyRes = null
export type UpdateProxyReq = {
name?: string
primaryInbound?: true
primaryOutbound?: true
} // net.proxy.update
export type UpdateProxyRes = null
export type DeleteProxyReq = { id: string } // net.proxy.delete
export type DeleteProxyRes = null
// domains
export type ClaimStart9ToReq = { networkStrategy: NetworkStrategy } // net.domain.me.claim
export type ClaimStart9ToRes = null
export type DeleteStart9ToReq = {} // net.domain.me.delete
export type DeleteStart9ToRes = null
export type AddDomainReq = {
hostname: string
provider: {
name: string
username: string | null
password: string | null
}
networkStrategy: NetworkStrategy
} // net.domain.add
export type AddDomainRes = null
export type DeleteDomainReq = { hostname: string } // net.domain.delete
export type DeleteDomainRes = null
// port forwards
export type OverridePortReq = { target: number; port: number } // net.port-forwards.override
export type OverridePortRes = 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
export type SetInterfaceClearnetAddressReq = SetServerClearnetAddressReq & {
packageId: string
interfaceId: string
} // package.interface.set-clearnet
export type SetInterfaceClearnetAddressRes = null
export type SetServiceOutboundProxyReq = {
packageId: string
proxy: ServiceOutboundProxy
} // package.proxy.set-outbound
export type SetServiceOutboundProxyRes = null
// 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,320 @@
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 setServerClearnetAddress(
params: RR.SetServerClearnetAddressReq,
): Promise<RR.SetServerClearnetAddressRes>
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>
abstract setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes>
// 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>
// network
abstract addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes>
abstract updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes>
abstract deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes>
// domains
abstract claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes>
abstract deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes>
abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
abstract deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes>
// port forwards
abstract overridePortForward(
params: RR.OverridePortReq,
): Promise<RR.OverridePortRes>
// 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.DeleteWifiRes>
// 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>
abstract setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes>
abstract setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes>
}

View File

@@ -0,0 +1,600 @@
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 setServerClearnetAddress(
params: RR.SetServerClearnetAddressReq,
): Promise<RR.SetServerClearnetAddressRes> {
return this.rpcRequest({ method: 'server.set-clearnet', 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 })
}
async setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes> {
return this.rpcRequest({ method: 'server.proxy.set-outbound', 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,
})
}
// network
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
return this.rpcRequest({ method: 'net.proxy.add', params })
}
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
return this.rpcRequest({ method: 'net.proxy.update', params })
}
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
return this.rpcRequest({ method: 'net.proxy.delete', params })
}
// domains
async claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.claim', params })
}
async deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.delete', params })
}
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
return this.rpcRequest({ method: 'net.domain.add', params })
}
async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
return this.rpcRequest({ method: 'net.domain.delete', params })
}
// port forwards
async overridePortForward(
params: RR.OverridePortReq,
): Promise<RR.OverridePortRes> {
return this.rpcRequest({ method: 'net.port-forwards.override', 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 setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes> {
return this.rpcRequest({ method: 'package.interface.set-clearnet', params })
}
async setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes> {
return this.rpcRequest({ method: 'package.proxy.set-outbound', 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,131 @@
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',
desktop: ['lnd'],
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',
ui: {
lanHostname: 'adjective-noun.local',
torHostname: 'myveryownspecialtoraddress.onion',
ipInfo: {
eth0: {
wireless: false,
ipv4: '10.0.0.1',
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD',
},
},
domainInfo: null,
},
network: {
domains: [],
start9ToSubdomain: null,
wifi: {
enabled: false,
lastRegion: null,
},
wanConfig: {
upnp: false,
forwards: [
{
assigned: 443,
override: null,
target: 443,
error: null,
},
{
assigned: 80,
override: null,
target: 80,
error: null,
},
{
assigned: 8332,
override: null,
target: 8332,
error: null,
},
],
},
proxies: [],
primaryProxies: {
inbound: null,
outbound: null,
},
outboundProxy: null,
},
'last-backup': new Date(new Date().valueOf() - 604800001).toISOString(),
'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,
},
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: '',
},
'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,
manifest: {
...Mock.bitcoind.manifest,
version: '0.19.0',
},
},
lnd: {
...Mock.lnd,
manifest: {
...Mock.lnd.manifest,
version: '0.11.0',
},
},
},
}

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,120 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import { InterfaceInfo } 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
isTor(): boolean {
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
}
isLocal(): boolean {
return (
this.hostname.endsWith('.local') || (useMocks && mocks.maskAs === 'local')
)
}
isLocalhost(): boolean {
return (
this.hostname === 'localhost' ||
(useMocks && mocks.maskAs === 'localhost')
)
}
isIpv4(): boolean {
return isValidIpv4(this.hostname) || (useMocks && mocks.maskAs === 'ipv4')
}
isIpv6(): boolean {
return isValidIpv6(this.hostname) || (useMocks && mocks.maskAs === 'ipv6')
}
isClearnet(): boolean {
return (
(useMocks && mocks.maskAs === 'clearnet') ||
(!this.isTor() &&
!this.isLocal() &&
!this.isLocalhost() &&
!this.isIpv4() &&
!this.isIpv6())
)
}
isTorHttp(): boolean {
return this.isTor() && !this.isHttps()
}
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
launchableAddress(info: InterfaceInfo): string {
return this.isTor()
? info.addressInfo.torHostname
: this.isLocalhost()
? `https://${info.addressInfo.lanHostname}`
: this.isLocal() || this.isIpv4() || this.isIpv6()
? `https://${this.hostname}`
: info.addressInfo.domainInfo?.subdomain
? `https://${info.addressInfo.domainInfo.subdomain}${info.addressInfo.domainInfo.domain}`
: `https://${info.addressInfo.domainInfo?.domain}`
}
getHost(): string {
return this.host
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
}
export function isValidIpv4(address: string): boolean {
const regexExp =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
return regexExp.test(address)
}
export function isValidIpv6(address: string): boolean {
const regexExp =
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/gi
return regexExp.test(address)
}
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,222 @@
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'
import { Observable } from 'rxjs'
export type AllDependencyErrors = Record<string, PkgDependencyErrors>
export type PkgDependencyErrors = Record<string, DependencyError | null>
@Injectable({
providedIn: 'root',
})
export class DepErrorService {
readonly depErrors$: Observable<AllDependencyErrors> = 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): Observable<PkgDependencyErrors> {
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'
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,68 @@
import { Inject, Injectable } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { TuiDialogService } from '@taiga-ui/core'
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 { ConnectionService } from 'src/app/services/connection.service'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
// 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 dialogs: TuiDialogService,
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 showEosWelcome(ackVersion: string) {
if (this.config.skipStartupAlerts || ackVersion === this.config.version) {
return
}
this.dialogs
.open(new PolymorpheusComponent(OSWelcomePage), {
label: 'Release Notes',
})
.subscribe({
complete: () => {
this.embassyApi
.setDbValue<string>(['ack-welcome'], this.config.version)
.catch()
},
})
}
}

View File

@@ -0,0 +1,382 @@
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'
import { NetworkInterfaceType } from '@start9labs/start-sdk/lib/util/utils'
import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info'
import { PackageStatus } from '../pkg-status-rendering.service'
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[]
desktop: readonly string[]
}
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
ui: AddressInfo
network: NetworkInfo
'last-backup': string | null
'unread-notification-count': number
'status-info': ServerStatusInfo
'eos-version-compat': string
pubkey: string
'ca-fingerprint': string
'ntp-synced': boolean
zram: boolean
smtp: typeof customSmtp.validator._TYPE
'password-hash': string
platform: string
}
export type NetworkInfo = {
wifi: WiFiInfo
start9ToSubdomain: Omit<Domain, 'provider'> | null
domains: Domain[]
wanConfig: {
upnp: boolean
forwards: PortForward[]
}
proxies: Proxy[]
outboundProxy: OsOutboundProxy
primaryProxies: {
inbound: string | null
outbound: string | null
}
}
export type DomainInfo = {
domain: string
subdomain: string | null
}
export type InboundProxy = { proxyId: string } | 'primary' | null
export type OsOutboundProxy = InboundProxy
export type ServiceOutboundProxy = OsOutboundProxy | 'mirror'
export type PortForward = {
assigned: number
override: number | null
target: number
error: string | null
}
export type WiFiInfo = {
enabled: boolean
lastRegion: string | null
}
export type Domain = {
value: string
createdAt: string
provider: string
networkStrategy: NetworkStrategy
usedBy: {
service: { id: string | null; title: string } // null means startos
interfaces: { id: string | null; title: string }[] // null means startos
}[]
}
export type NetworkStrategy =
| { proxyId: string | null } // null means system primary
| { ipStrategy: 'ipv4' | 'ipv6' | 'dualstack' }
export type Proxy = {
id: string
name: string
createdAt: string
type: 'outbound' | 'inbound-outbound' | 'vlan' | { error: string }
endpoint: string
// below is overlay only
usedBy: {
services: { id: string | null; title: string }[] // implies outbound - null means startos
domains: string[] // implies inbound
}
primaryInbound: boolean
primaryOutbound: boolean
}
export interface IpInfo {
[iface: string]: {
wireless: boolean
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 PackagePlus = {
pkg: PackageDataEntry
status: PackageStatus
dependencies: DependencyInfo[]
}
// 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 }>
interfaceInfo: Record<string, InterfaceInfo>
'marketplace-url': string | null
'developer-key': string
'has-config': boolean
outboundProxy: ServiceOutboundProxy
}
export interface CurrentDependencyInfo {
'health-checks': string[] // array of health check IDs
}
export interface InterfaceInfo {
name: string
description: string
type: NetworkInterfaceType
addressInfo: AddressInfo
}
export interface AddressInfo {
ipInfo: IpInfo
lanHostname: string
torHostname: string
domainInfo: DomainInfo | null
}
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,175 @@
import {
PackageDataEntry,
PackageMainStatus,
PackagePlus,
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,161 @@
import { Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
OsOutboundProxy,
ServiceOutboundProxy,
} from './patch-db/data-model'
import { firstValueFrom } from 'rxjs'
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { TuiDialogOptions } from '@taiga-ui/core'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormContext, FormPage } from '../apps/ui/modals/form/form.page'
import { ApiService } from './api/embassy-api.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class ProxyService {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
private readonly api: ApiService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
) {}
async presentModalSetOutboundProxy(serviceContext?: {
packageId: string
outboundProxy: ServiceOutboundProxy
hasP2P: boolean
}) {
const network = await firstValueFrom(
this.patch.watch$('server-info', 'network'),
)
const outboundProxy = serviceContext?.outboundProxy
const defaultValue = !outboundProxy
? 'none'
: outboundProxy === 'primary'
? 'primary'
: outboundProxy === 'mirror'
? 'mirror'
: 'other'
let variants: Record<string, { name: string; spec: Config<any> }> = {}
if (serviceContext) {
variants['mirror'] = {
name: 'Mirror P2P Interface',
spec: Config.of({}),
}
}
variants = {
...variants,
primary: {
name: 'Use System Primary',
spec: Config.of({}),
},
other: {
name: 'Other',
spec: Config.of({
proxyId: Value.select({
name: 'Select Specific Proxy',
required: {
default:
outboundProxy && typeof outboundProxy !== 'string'
? outboundProxy.proxyId
: null,
},
values: network.proxies
.filter(
p => p.type === 'outbound' || p.type === 'inbound-outbound',
)
.reduce((prev, curr) => {
return {
[curr.id]: curr.name,
...prev,
}
}, {}),
}),
}),
},
none: {
name: 'None',
spec: Config.of({}),
},
}
const config = Config.of({
proxy: Value.union(
{
name: 'Select Proxy',
required: { default: defaultValue },
description: `
<h5>Use System Primary</h5>The primary <i>inbound</i> proxy will be used. If you do not have a primary inbound proxy, no proxy will be used
<h5>Mirror Primary Interface</h5>If you have an inbound proxy enabled for the primary interface, outbound traffic will flow through the same proxy
<h5>Other</h5>The specific proxy you select will be used, overriding the default
`,
disabled: serviceContext?.hasP2P ? [] : ['mirror'],
},
Variants.of(variants),
),
})
const options: Partial<
TuiDialogOptions<FormContext<typeof config.validator._TYPE>>
> = {
label: 'Outbound Proxy',
data: {
spec: await configBuilderToSpec(config),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => {
const proxy =
value.proxy.unionSelectKey === 'none'
? null
: value.proxy.unionSelectKey === 'primary'
? 'primary'
: value.proxy.unionSelectKey === 'mirror'
? 'mirror'
: { proxyId: value.proxy.unionValueKey.proxyId }
await this.saveOutboundProxy(proxy, serviceContext?.packageId)
return true
},
},
],
},
}
this.formDialog.open(FormPage, options)
}
private async saveOutboundProxy(
proxy: OsOutboundProxy | ServiceOutboundProxy,
packageId?: string,
) {
const loader = this.loader.open(`Saving`).subscribe()
try {
if (packageId) {
await this.api.setServiceOutboundProxy({ packageId, proxy })
} else {
await this.api.setOsOutboundProxy({ proxy: proxy as OsOutboundProxy })
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

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,
) {}
}