mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
rename frontend to web and update contributing guide (#2509)
* rename frontend to web and update contributing guide * rename this time * fix build * restructure rust code * update documentation * update descriptions * Update CONTRIBUTING.md Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com> --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>
This commit is contained in:
8
web/projects/ui/src/app/services/api/api-icons.ts
Normal file
8
web/projects/ui/src/app/services/api/api-icons.ts
Normal file
File diff suppressed because one or more lines are too long
2031
web/projects/ui/src/app/services/api/api.fixures.ts
Normal file
2031
web/projects/ui/src/app/services/api/api.fixures.ts
Normal file
File diff suppressed because it is too large
Load Diff
541
web/projects/ui/src/app/services/api/api.types.ts
Normal file
541
web/projects/ui/src/app/services/api/api.types.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { Dump, Revision } from 'patch-db-client'
|
||||
import { MarketplacePkg, StoreInfo } from '@start9labs/marketplace'
|
||||
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import {
|
||||
DataModel,
|
||||
HealthCheckResult,
|
||||
Manifest,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
|
||||
export module RR {
|
||||
// DB
|
||||
|
||||
export type GetRevisionsRes = Revision[] | Dump<DataModel>
|
||||
|
||||
export type GetDumpRes = Dump<DataModel>
|
||||
|
||||
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
|
||||
export type SetDBValueRes = null
|
||||
|
||||
// auth
|
||||
|
||||
export type LoginReq = {
|
||||
password: string
|
||||
metadata: SessionMetadata
|
||||
} // auth.login - unauthed
|
||||
export type loginRes = null
|
||||
|
||||
export type LogoutReq = {} // auth.logout
|
||||
export type LogoutRes = null
|
||||
|
||||
export type ResetPasswordReq = {
|
||||
'old-password': string
|
||||
'new-password': string
|
||||
} // auth.reset-password
|
||||
export type ResetPasswordRes = null
|
||||
|
||||
// server
|
||||
|
||||
export type EchoReq = { message: string; timeout?: number } // server.echo
|
||||
export type EchoRes = string
|
||||
|
||||
export type GetSystemTimeReq = {} // server.time
|
||||
export type GetSystemTimeRes = {
|
||||
now: string
|
||||
uptime: number // seconds
|
||||
}
|
||||
|
||||
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
|
||||
export type GetServerLogsRes = LogsRes
|
||||
|
||||
export type FollowServerLogsReq = { limit?: number } // server.logs.follow & server.kernel-logs.follow
|
||||
export type FollowServerLogsRes = {
|
||||
'start-cursor': string
|
||||
guid: string
|
||||
}
|
||||
|
||||
export type GetServerMetricsReq = {} // server.metrics
|
||||
export type GetServerMetricsRes = Metrics
|
||||
|
||||
export type UpdateServerReq = { 'marketplace-url': string } // server.update
|
||||
export type UpdateServerRes = 'updating' | 'no-updates'
|
||||
|
||||
export type RestartServerReq = {} // server.restart
|
||||
export type RestartServerRes = null
|
||||
|
||||
export type ShutdownServerReq = {} // server.shutdown
|
||||
export type ShutdownServerRes = null
|
||||
|
||||
export type SystemRebuildReq = {} // server.rebuild
|
||||
export type SystemRebuildRes = null
|
||||
|
||||
export type ResetTorReq = {
|
||||
'wipe-state': boolean
|
||||
reason: string
|
||||
} // net.tor.reset
|
||||
export type ResetTorRes = null
|
||||
|
||||
export type ToggleZramReq = {
|
||||
enable: boolean
|
||||
} // server.experimental.zram
|
||||
export type ToggleZramRes = null
|
||||
|
||||
// sessions
|
||||
|
||||
export type GetSessionsReq = {} // sessions.list
|
||||
export type GetSessionsRes = {
|
||||
current: string
|
||||
sessions: { [hash: string]: Session }
|
||||
}
|
||||
|
||||
export type KillSessionsReq = { ids: string[] } // sessions.kill
|
||||
export type KillSessionsRes = null
|
||||
|
||||
// notification
|
||||
|
||||
export type GetNotificationsReq = {
|
||||
before?: number
|
||||
limit?: number
|
||||
} // notification.list
|
||||
export type GetNotificationsRes = ServerNotification<number>[]
|
||||
|
||||
export type DeleteNotificationReq = { id: number } // notification.delete
|
||||
export type DeleteNotificationRes = null
|
||||
|
||||
export type DeleteAllNotificationsReq = { before: number } // notification.delete-before
|
||||
export type DeleteAllNotificationsRes = null
|
||||
|
||||
// wifi
|
||||
|
||||
export type SetWifiCountryReq = { country: string }
|
||||
export type SetWifiCountryRes = null
|
||||
|
||||
export type GetWifiReq = {}
|
||||
export type GetWifiRes = {
|
||||
ssids: {
|
||||
[ssid: string]: number
|
||||
}
|
||||
connected: string | null
|
||||
country: string | null
|
||||
ethernet: boolean
|
||||
'available-wifi': AvailableWifi[]
|
||||
}
|
||||
|
||||
export type AddWifiReq = {
|
||||
// wifi.add
|
||||
ssid: string
|
||||
password: string
|
||||
priority: number
|
||||
connect: boolean
|
||||
}
|
||||
export type AddWifiRes = null
|
||||
|
||||
export type ConnectWifiReq = { ssid: string } // wifi.connect
|
||||
export type ConnectWifiRes = null
|
||||
|
||||
export type DeleteWifiReq = { ssid: string } // wifi.delete
|
||||
export type DeleteWifiRes = null
|
||||
|
||||
// ssh
|
||||
|
||||
export type GetSSHKeysReq = {} // ssh.list
|
||||
export type GetSSHKeysRes = SSHKey[]
|
||||
|
||||
export type AddSSHKeyReq = { key: string } // ssh.add
|
||||
export type AddSSHKeyRes = SSHKey
|
||||
|
||||
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.delete
|
||||
export type DeleteSSHKeyRes = null
|
||||
|
||||
// backup
|
||||
|
||||
export type GetBackupTargetsReq = {} // backup.target.list
|
||||
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
|
||||
|
||||
export type AddBackupTargetReq = {
|
||||
// backup.target.cifs.add
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
|
||||
|
||||
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
|
||||
export type UpdateBackupTargetRes = AddBackupTargetRes
|
||||
|
||||
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
|
||||
export type RemoveBackupTargetRes = null
|
||||
|
||||
export type GetBackupInfoReq = { 'target-id': string; password: string } // backup.target.info
|
||||
export type GetBackupInfoRes = BackupInfo
|
||||
|
||||
export type CreateBackupReq = {
|
||||
// backup.create
|
||||
'target-id': string
|
||||
'package-ids': string[]
|
||||
'old-password': string | null
|
||||
password: string
|
||||
}
|
||||
export type CreateBackupRes = null
|
||||
|
||||
// package
|
||||
|
||||
export type GetPackagePropertiesReq = { id: string } // package.properties
|
||||
export type GetPackagePropertiesRes<T extends number> =
|
||||
PackagePropertiesVersioned<T>
|
||||
|
||||
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
|
||||
export type GetPackageLogsRes = LogsRes
|
||||
|
||||
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
|
||||
export type FollowPackageLogsRes = FollowServerLogsRes
|
||||
|
||||
export type GetPackageMetricsReq = { id: string } // package.metrics
|
||||
export type GetPackageMetricsRes = Metric
|
||||
|
||||
export type InstallPackageReq = {
|
||||
id: string
|
||||
'version-spec'?: string
|
||||
'version-priority'?: 'min' | 'max'
|
||||
'marketplace-url': string
|
||||
} // package.install
|
||||
export type InstallPackageRes = null
|
||||
|
||||
export type GetPackageConfigReq = { id: string } // package.config.get
|
||||
export type GetPackageConfigRes = { spec: ConfigSpec; config: object }
|
||||
|
||||
export type DrySetPackageConfigReq = { id: string; config: object } // package.config.set.dry
|
||||
export type DrySetPackageConfigRes = Breakages
|
||||
|
||||
export type SetPackageConfigReq = DrySetPackageConfigReq // package.config.set
|
||||
export type SetPackageConfigRes = null
|
||||
|
||||
export type RestorePackagesReq = {
|
||||
// package.backup.restore
|
||||
ids: string[]
|
||||
'target-id': string
|
||||
'old-password': string | null
|
||||
password: string
|
||||
}
|
||||
export type RestorePackagesRes = null
|
||||
|
||||
export type ExecutePackageActionReq = {
|
||||
id: string
|
||||
'action-id': string
|
||||
input?: object
|
||||
} // package.action
|
||||
export type ExecutePackageActionRes = ActionResponse
|
||||
|
||||
export type StartPackageReq = { id: string } // package.start
|
||||
export type StartPackageRes = null
|
||||
|
||||
export type RestartPackageReq = { id: string } // package.restart
|
||||
export type RestartPackageRes = null
|
||||
|
||||
export type StopPackageReq = { id: string } // package.stop
|
||||
export type StopPackageRes = null
|
||||
|
||||
export type UninstallPackageReq = { id: string } // package.uninstall
|
||||
export type UninstallPackageRes = null
|
||||
|
||||
export type DryConfigureDependencyReq = {
|
||||
'dependency-id': string
|
||||
'dependent-id': string
|
||||
} // package.dependency.configure.dry
|
||||
export type DryConfigureDependencyRes = {
|
||||
'old-config': object
|
||||
'new-config': object
|
||||
spec: ConfigSpec
|
||||
}
|
||||
|
||||
export type SideloadPackageReq = {
|
||||
manifest: Manifest
|
||||
icon: string // base64
|
||||
}
|
||||
export type SideloadPacakgeRes = string //guid
|
||||
|
||||
// marketplace
|
||||
|
||||
export type GetMarketplaceInfoReq = { 'server-id': string }
|
||||
export type GetMarketplaceInfoRes = StoreInfo
|
||||
|
||||
export type GetMarketplaceEosReq = { 'server-id': string }
|
||||
export type GetMarketplaceEosRes = MarketplaceEOS
|
||||
|
||||
export type GetMarketplacePackagesReq = {
|
||||
ids?: { id: string; version: string }[]
|
||||
// iff !ids
|
||||
category?: string
|
||||
query?: string
|
||||
page?: number
|
||||
'per-page'?: number
|
||||
}
|
||||
export type GetMarketplacePackagesRes = MarketplacePkg[]
|
||||
|
||||
export type GetReleaseNotesReq = { id: string }
|
||||
export type GetReleaseNotesRes = { [version: string]: string }
|
||||
}
|
||||
|
||||
export interface MarketplaceEOS {
|
||||
version: string
|
||||
headline: string
|
||||
'release-notes': { [version: string]: string }
|
||||
}
|
||||
|
||||
export interface Breakages {
|
||||
[id: string]: TaggedDependencyError
|
||||
}
|
||||
|
||||
export interface TaggedDependencyError {
|
||||
dependency: string
|
||||
error: DependencyError
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
message: string
|
||||
value: string | null
|
||||
copyable: boolean
|
||||
qr: boolean
|
||||
}
|
||||
|
||||
interface MetricData {
|
||||
value: string
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
general: {
|
||||
temperature: MetricData | null
|
||||
}
|
||||
memory: {
|
||||
total: MetricData
|
||||
'percentage-used': MetricData
|
||||
used: MetricData
|
||||
available: MetricData
|
||||
'zram-total': MetricData
|
||||
'zram-used': MetricData
|
||||
'zram-available': MetricData
|
||||
}
|
||||
cpu: {
|
||||
'percentage-used': MetricData
|
||||
idle: MetricData
|
||||
'user-space': MetricData
|
||||
'kernel-space': MetricData
|
||||
wait: MetricData
|
||||
}
|
||||
disk: {
|
||||
capacity: MetricData
|
||||
'percentage-used': MetricData
|
||||
used: MetricData
|
||||
available: MetricData
|
||||
}
|
||||
}
|
||||
|
||||
export interface Metric {
|
||||
[key: string]: {
|
||||
value: string | number | null
|
||||
unit?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
'last-active': string
|
||||
'user-agent': string
|
||||
metadata: SessionMetadata
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
platforms: PlatformType[]
|
||||
}
|
||||
|
||||
export type PlatformType =
|
||||
| 'cli'
|
||||
| 'ios'
|
||||
| 'ipad'
|
||||
| 'iphone'
|
||||
| 'android'
|
||||
| 'phablet'
|
||||
| 'tablet'
|
||||
| 'cordova'
|
||||
| 'capacitor'
|
||||
| 'electron'
|
||||
| 'pwa'
|
||||
| 'mobile'
|
||||
| 'mobileweb'
|
||||
| 'desktop'
|
||||
| 'hybrid'
|
||||
|
||||
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
|
||||
|
||||
export interface DiskBackupTarget {
|
||||
type: 'disk'
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
logicalname: string | null
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
'embassy-os': StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export interface CifsBackupTarget {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
'embassy-os': StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
|
||||
|
||||
export interface DiskRecoverySource {
|
||||
type: 'disk'
|
||||
logicalname: string // partition logicalname
|
||||
}
|
||||
|
||||
export interface CifsRecoverySource {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
version: string
|
||||
timestamp: string
|
||||
'package-backups': {
|
||||
[id: string]: PackageBackupInfo
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackageBackupInfo {
|
||||
title: string
|
||||
version: string
|
||||
'os-version': string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface ServerSpecs {
|
||||
[key: string]: string | number
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
'created-at': string
|
||||
alg: string
|
||||
hostname: string
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export type ServerNotifications = ServerNotification<any>[]
|
||||
|
||||
export interface ServerNotification<T extends number> {
|
||||
id: number
|
||||
'package-id': string | null
|
||||
'created-at': string
|
||||
code: T
|
||||
level: NotificationLevel
|
||||
title: string
|
||||
message: string
|
||||
data: NotificationData<T>
|
||||
}
|
||||
|
||||
export enum NotificationLevel {
|
||||
Success = 'success',
|
||||
Info = 'info',
|
||||
Warning = 'warning',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type NotificationData<T> = T extends 0
|
||||
? null
|
||||
: T extends 1
|
||||
? BackupReport
|
||||
: any
|
||||
|
||||
export interface BackupReport {
|
||||
server: {
|
||||
attempted: boolean
|
||||
error: string | null
|
||||
}
|
||||
packages: {
|
||||
[id: string]: {
|
||||
error: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AvailableWifi {
|
||||
ssid: string
|
||||
strength: number
|
||||
security: string[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
type Stringified<T> = string & {
|
||||
[P in keyof T]: T[P]
|
||||
}
|
||||
|
||||
interface JSON {
|
||||
stringify<T>(
|
||||
value: T,
|
||||
replacer?: (key: string, value: any) => any,
|
||||
space?: string | number,
|
||||
): string & Stringified<T>
|
||||
parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T
|
||||
}
|
||||
}
|
||||
|
||||
export type Encrypted = {
|
||||
encrypted: string
|
||||
}
|
||||
|
||||
export type DependencyError =
|
||||
| DependencyErrorNotInstalled
|
||||
| DependencyErrorNotRunning
|
||||
| DependencyErrorIncorrectVersion
|
||||
| DependencyErrorConfigUnsatisfied
|
||||
| DependencyErrorHealthChecksFailed
|
||||
| DependencyErrorTransitive
|
||||
|
||||
export enum DependencyErrorType {
|
||||
NotInstalled = 'not-installed',
|
||||
NotRunning = 'not-running',
|
||||
IncorrectVersion = 'incorrect-version',
|
||||
ConfigUnsatisfied = 'config-unsatisfied',
|
||||
HealthChecksFailed = 'health-checks-failed',
|
||||
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
|
||||
Transitive = 'transitive',
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotInstalled {
|
||||
type: DependencyErrorType.NotInstalled
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotRunning {
|
||||
type: DependencyErrorType.NotRunning
|
||||
}
|
||||
|
||||
export interface DependencyErrorIncorrectVersion {
|
||||
type: DependencyErrorType.IncorrectVersion
|
||||
expected: string // version range
|
||||
received: string // version
|
||||
}
|
||||
|
||||
export interface DependencyErrorConfigUnsatisfied {
|
||||
type: DependencyErrorType.ConfigUnsatisfied
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface DependencyErrorHealthChecksFailed {
|
||||
type: DependencyErrorType.HealthChecksFailed
|
||||
check: HealthCheckResult
|
||||
}
|
||||
|
||||
export interface DependencyErrorTransitive {
|
||||
type: DependencyErrorType.Transitive
|
||||
}
|
||||
232
web/projects/ui/src/app/services/api/embassy-api.service.ts
Normal file
232
web/projects/ui/src/app/services/api/embassy-api.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { Update } from 'patch-db-client'
|
||||
import { RR } from './api.types'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { Log } from '@start9labs/shared'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
export abstract class ApiService {
|
||||
// http
|
||||
|
||||
// for getting static files: ex icons, instructions, licenses
|
||||
abstract getStatic(url: string): Promise<string>
|
||||
|
||||
// for sideloading packages
|
||||
abstract uploadPackage(guid: string, body: Blob): Promise<string>
|
||||
|
||||
// db
|
||||
|
||||
abstract setDbValue<T>(
|
||||
pathArr: Array<string | number>,
|
||||
value: T,
|
||||
): Promise<RR.SetDBValueRes>
|
||||
|
||||
// auth
|
||||
|
||||
abstract login(params: RR.LoginReq): Promise<RR.loginRes>
|
||||
|
||||
abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes>
|
||||
|
||||
abstract getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes>
|
||||
|
||||
abstract killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes>
|
||||
|
||||
abstract resetPassword(
|
||||
params: RR.ResetPasswordReq,
|
||||
): Promise<RR.ResetPasswordRes>
|
||||
|
||||
// server
|
||||
|
||||
abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
|
||||
|
||||
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
|
||||
|
||||
abstract openLogsWebsocket$(
|
||||
config: WebSocketSubjectConfig<Log>,
|
||||
): Observable<Log>
|
||||
|
||||
abstract getSystemTime(
|
||||
params: RR.GetSystemTimeReq,
|
||||
): Promise<RR.GetSystemTimeRes>
|
||||
|
||||
abstract getServerLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes>
|
||||
|
||||
abstract getKernelLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes>
|
||||
|
||||
abstract getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
|
||||
|
||||
abstract followServerLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes>
|
||||
|
||||
abstract followKernelLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes>
|
||||
|
||||
abstract followTorLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes>
|
||||
|
||||
abstract getServerMetrics(
|
||||
params: RR.GetServerMetricsReq,
|
||||
): Promise<RR.GetServerMetricsRes>
|
||||
|
||||
abstract getPkgMetrics(
|
||||
params: RR.GetPackageMetricsReq,
|
||||
): Promise<RR.GetPackageMetricsRes>
|
||||
|
||||
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
|
||||
|
||||
abstract restartServer(
|
||||
params: RR.RestartServerReq,
|
||||
): Promise<RR.RestartServerRes>
|
||||
|
||||
abstract shutdownServer(
|
||||
params: RR.ShutdownServerReq,
|
||||
): Promise<RR.ShutdownServerRes>
|
||||
|
||||
abstract systemRebuild(
|
||||
params: RR.SystemRebuildReq,
|
||||
): Promise<RR.SystemRebuildRes>
|
||||
|
||||
abstract repairDisk(params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
|
||||
|
||||
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
|
||||
|
||||
abstract toggleZram(params: RR.ToggleZramReq): Promise<RR.ToggleZramRes>
|
||||
|
||||
// marketplace URLs
|
||||
|
||||
abstract marketplaceProxy<T>(
|
||||
path: string,
|
||||
params: Record<string, unknown>,
|
||||
url: string,
|
||||
): Promise<T>
|
||||
|
||||
abstract getEos(): Promise<RR.GetMarketplaceEosRes>
|
||||
|
||||
// notification
|
||||
|
||||
abstract getNotifications(
|
||||
params: RR.GetNotificationsReq,
|
||||
): Promise<RR.GetNotificationsRes>
|
||||
|
||||
abstract deleteNotification(
|
||||
params: RR.DeleteNotificationReq,
|
||||
): Promise<RR.DeleteNotificationRes>
|
||||
|
||||
abstract deleteAllNotifications(
|
||||
params: RR.DeleteAllNotificationsReq,
|
||||
): Promise<RR.DeleteAllNotificationsRes>
|
||||
|
||||
// wifi
|
||||
|
||||
abstract getWifi(
|
||||
params: RR.GetWifiReq,
|
||||
timeout: number,
|
||||
): Promise<RR.GetWifiRes>
|
||||
|
||||
abstract setWifiCountry(
|
||||
params: RR.SetWifiCountryReq,
|
||||
): Promise<RR.SetWifiCountryRes>
|
||||
|
||||
abstract addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes>
|
||||
|
||||
abstract connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
|
||||
|
||||
abstract deleteWifi(params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
|
||||
|
||||
// ssh
|
||||
|
||||
abstract getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes>
|
||||
|
||||
abstract addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes>
|
||||
|
||||
abstract deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes>
|
||||
|
||||
// backup
|
||||
|
||||
abstract getBackupTargets(
|
||||
params: RR.GetBackupTargetsReq,
|
||||
): Promise<RR.GetBackupTargetsRes>
|
||||
|
||||
abstract addBackupTarget(
|
||||
params: RR.AddBackupTargetReq,
|
||||
): Promise<RR.AddBackupTargetRes>
|
||||
|
||||
abstract updateBackupTarget(
|
||||
params: RR.UpdateBackupTargetReq,
|
||||
): Promise<RR.UpdateBackupTargetRes>
|
||||
|
||||
abstract removeBackupTarget(
|
||||
params: RR.RemoveBackupTargetReq,
|
||||
): Promise<RR.RemoveBackupTargetRes>
|
||||
|
||||
abstract getBackupInfo(
|
||||
params: RR.GetBackupInfoReq,
|
||||
): Promise<RR.GetBackupInfoRes>
|
||||
|
||||
abstract createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
|
||||
|
||||
// package
|
||||
|
||||
abstract getPackageProperties(
|
||||
params: RR.GetPackagePropertiesReq,
|
||||
): Promise<RR.GetPackagePropertiesRes<2>['data']>
|
||||
|
||||
abstract getPackageLogs(
|
||||
params: RR.GetPackageLogsReq,
|
||||
): Promise<RR.GetPackageLogsRes>
|
||||
|
||||
abstract followPackageLogs(
|
||||
params: RR.FollowPackageLogsReq,
|
||||
): Promise<RR.FollowPackageLogsRes>
|
||||
|
||||
abstract installPackage(
|
||||
params: RR.InstallPackageReq,
|
||||
): Promise<RR.InstallPackageRes>
|
||||
|
||||
abstract getPackageConfig(
|
||||
params: RR.GetPackageConfigReq,
|
||||
): Promise<RR.GetPackageConfigRes>
|
||||
|
||||
abstract drySetPackageConfig(
|
||||
params: RR.DrySetPackageConfigReq,
|
||||
): Promise<RR.DrySetPackageConfigRes>
|
||||
|
||||
abstract setPackageConfig(
|
||||
params: RR.SetPackageConfigReq,
|
||||
): Promise<RR.SetPackageConfigRes>
|
||||
|
||||
abstract restorePackages(
|
||||
params: RR.RestorePackagesReq,
|
||||
): Promise<RR.RestorePackagesRes>
|
||||
|
||||
abstract executePackageAction(
|
||||
params: RR.ExecutePackageActionReq,
|
||||
): Promise<RR.ExecutePackageActionRes>
|
||||
|
||||
abstract startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes>
|
||||
|
||||
abstract restartPackage(
|
||||
params: RR.RestartPackageReq,
|
||||
): Promise<RR.RestartPackageRes>
|
||||
|
||||
abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes>
|
||||
|
||||
abstract uninstallPackage(
|
||||
params: RR.UninstallPackageReq,
|
||||
): Promise<RR.UninstallPackageRes>
|
||||
|
||||
abstract dryConfigureDependency(
|
||||
params: RR.DryConfigureDependencyReq,
|
||||
): Promise<RR.DryConfigureDependencyRes>
|
||||
|
||||
abstract sideloadPackage(
|
||||
params: RR.SideloadPackageReq,
|
||||
): Promise<RR.SideloadPacakgeRes>
|
||||
}
|
||||
464
web/projects/ui/src/app/services/api/embassy-live-api.service.ts
Normal file
464
web/projects/ui/src/app/services/api/embassy-live-api.service.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpOptions,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
Log,
|
||||
Method,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { ApiService } from './embassy-api.service'
|
||||
import { RR } from './api.types'
|
||||
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
||||
import { ConfigService } from '../config.service'
|
||||
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable, filter, firstValueFrom } from 'rxjs'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { DataModel } from '../patch-db/data-model'
|
||||
import { PatchDB, pathFromArray, Update } from 'patch-db-client'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService extends ApiService {
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly http: HttpService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {
|
||||
super()
|
||||
; (window as any).rpcClient = this
|
||||
}
|
||||
|
||||
// for getting static files: ex icons, instructions, licenses
|
||||
async getStatic(url: string): Promise<string> {
|
||||
return this.httpRequest({
|
||||
method: Method.GET,
|
||||
url,
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
// for sideloading packages
|
||||
async uploadPackage(guid: string, body: Blob): Promise<string> {
|
||||
return this.httpRequest({
|
||||
method: Method.POST,
|
||||
body,
|
||||
url: `/rest/rpc/${guid}`,
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
// db
|
||||
|
||||
async setDbValue<T>(
|
||||
pathArr: Array<string | number>,
|
||||
value: T,
|
||||
): Promise<RR.SetDBValueRes> {
|
||||
const pointer = pathFromArray(pathArr)
|
||||
const params: RR.SetDBValueReq<T> = { pointer, value }
|
||||
return this.rpcRequest({ method: 'db.put.ui', params })
|
||||
}
|
||||
|
||||
// auth
|
||||
|
||||
async login(params: RR.LoginReq): Promise<RR.loginRes> {
|
||||
return this.rpcRequest({ method: 'auth.login', params })
|
||||
}
|
||||
|
||||
async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> {
|
||||
return this.rpcRequest({ method: 'auth.logout', params })
|
||||
}
|
||||
|
||||
async getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
|
||||
return this.rpcRequest({ method: 'auth.session.list', params })
|
||||
}
|
||||
|
||||
async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
|
||||
return this.rpcRequest({ method: 'auth.session.kill', params })
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
params: RR.ResetPasswordReq,
|
||||
): Promise<RR.ResetPasswordRes> {
|
||||
return this.rpcRequest({ method: 'auth.reset-password', params })
|
||||
}
|
||||
|
||||
// server
|
||||
|
||||
async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
|
||||
return this.rpcRequest({ method: 'echo', params }, urlOverride)
|
||||
}
|
||||
|
||||
openPatchWebsocket$(): Observable<Update<DataModel>> {
|
||||
const config: WebSocketSubjectConfig<Update<DataModel>> = {
|
||||
url: `/db`,
|
||||
closeObserver: {
|
||||
next: val => {
|
||||
if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return this.openWebsocket(config)
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return this.openWebsocket(config)
|
||||
}
|
||||
|
||||
async getSystemTime(
|
||||
params: RR.GetSystemTimeReq,
|
||||
): Promise<RR.GetSystemTimeRes> {
|
||||
return this.rpcRequest({ method: 'server.time', params })
|
||||
}
|
||||
|
||||
async getServerLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'server.logs', params })
|
||||
}
|
||||
|
||||
async getKernelLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'server.kernel-logs', params })
|
||||
}
|
||||
|
||||
async getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'net.tor.logs', params })
|
||||
}
|
||||
|
||||
async followServerLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'server.logs.follow', params })
|
||||
}
|
||||
|
||||
async followKernelLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'server.kernel-logs.follow', params })
|
||||
}
|
||||
|
||||
async followTorLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'net.tor.logs.follow', params })
|
||||
}
|
||||
|
||||
async getServerMetrics(
|
||||
params: RR.GetServerMetricsReq,
|
||||
): Promise<RR.GetServerMetricsRes> {
|
||||
return this.rpcRequest({ method: 'server.metrics', params })
|
||||
}
|
||||
|
||||
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
|
||||
const params = {
|
||||
'marketplace-url': url || this.config.marketplace.start9,
|
||||
}
|
||||
return this.rpcRequest({ method: 'server.update', params })
|
||||
}
|
||||
|
||||
async restartServer(
|
||||
params: RR.RestartServerReq,
|
||||
): Promise<RR.RestartServerRes> {
|
||||
return this.rpcRequest({ method: 'server.restart', params })
|
||||
}
|
||||
|
||||
async shutdownServer(
|
||||
params: RR.ShutdownServerReq,
|
||||
): Promise<RR.ShutdownServerRes> {
|
||||
return this.rpcRequest({ method: 'server.shutdown', params })
|
||||
}
|
||||
|
||||
async systemRebuild(
|
||||
params: RR.RestartServerReq,
|
||||
): Promise<RR.RestartServerRes> {
|
||||
return this.rpcRequest({ method: 'server.rebuild', params })
|
||||
}
|
||||
|
||||
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
|
||||
return this.rpcRequest({ method: 'disk.repair', params })
|
||||
}
|
||||
|
||||
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
|
||||
return this.rpcRequest({ method: 'net.tor.reset', params })
|
||||
}
|
||||
|
||||
async toggleZram(params: RR.ToggleZramReq): Promise<RR.ToggleZramRes> {
|
||||
return this.rpcRequest({ method: 'server.experimental.zram', params })
|
||||
}
|
||||
|
||||
// marketplace URLs
|
||||
|
||||
async marketplaceProxy<T>(
|
||||
path: string,
|
||||
qp: Record<string, string>,
|
||||
baseUrl: string,
|
||||
): Promise<T> {
|
||||
const fullUrl = `${baseUrl}${path}?${new URLSearchParams(qp).toString()}`
|
||||
return this.rpcRequest({
|
||||
method: 'marketplace.get',
|
||||
params: { url: fullUrl },
|
||||
})
|
||||
}
|
||||
|
||||
async getEos(): Promise<RR.GetMarketplaceEosRes> {
|
||||
const { id } = await getServerInfo(this.patch)
|
||||
const qp: RR.GetMarketplaceEosReq = { 'server-id': id }
|
||||
|
||||
return this.marketplaceProxy(
|
||||
'/eos/v0/latest',
|
||||
qp,
|
||||
this.config.marketplace.start9,
|
||||
)
|
||||
}
|
||||
|
||||
// notification
|
||||
|
||||
async getNotifications(
|
||||
params: RR.GetNotificationsReq,
|
||||
): Promise<RR.GetNotificationsRes> {
|
||||
return this.rpcRequest({ method: 'notification.list', params })
|
||||
}
|
||||
|
||||
async deleteNotification(
|
||||
params: RR.DeleteNotificationReq,
|
||||
): Promise<RR.DeleteNotificationRes> {
|
||||
return this.rpcRequest({ method: 'notification.delete', params })
|
||||
}
|
||||
|
||||
async deleteAllNotifications(
|
||||
params: RR.DeleteAllNotificationsReq,
|
||||
): Promise<RR.DeleteAllNotificationsRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'notification.delete-before',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// wifi
|
||||
|
||||
async getWifi(
|
||||
params: RR.GetWifiReq,
|
||||
timeout?: number,
|
||||
): Promise<RR.GetWifiRes> {
|
||||
return this.rpcRequest({ method: 'wifi.get', params, timeout })
|
||||
}
|
||||
|
||||
async setWifiCountry(
|
||||
params: RR.SetWifiCountryReq,
|
||||
): Promise<RR.SetWifiCountryRes> {
|
||||
return this.rpcRequest({ method: 'wifi.country.set', params })
|
||||
}
|
||||
|
||||
async addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
|
||||
return this.rpcRequest({ method: 'wifi.add', params })
|
||||
}
|
||||
|
||||
async connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
|
||||
return this.rpcRequest({ method: 'wifi.connect', params })
|
||||
}
|
||||
|
||||
async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
|
||||
return this.rpcRequest({ method: 'wifi.delete', params })
|
||||
}
|
||||
|
||||
// ssh
|
||||
|
||||
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
|
||||
return this.rpcRequest({ method: 'ssh.list', params })
|
||||
}
|
||||
|
||||
async addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
|
||||
return this.rpcRequest({ method: 'ssh.add', params })
|
||||
}
|
||||
|
||||
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
|
||||
return this.rpcRequest({ method: 'ssh.delete', params })
|
||||
}
|
||||
|
||||
// backup
|
||||
|
||||
async getBackupTargets(
|
||||
params: RR.GetBackupTargetsReq,
|
||||
): Promise<RR.GetBackupTargetsRes> {
|
||||
return this.rpcRequest({ method: 'backup.target.list', params })
|
||||
}
|
||||
|
||||
async addBackupTarget(
|
||||
params: RR.AddBackupTargetReq,
|
||||
): Promise<RR.AddBackupTargetRes> {
|
||||
params.path = params.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest({ method: 'backup.target.cifs.add', params })
|
||||
}
|
||||
|
||||
async updateBackupTarget(
|
||||
params: RR.UpdateBackupTargetReq,
|
||||
): Promise<RR.UpdateBackupTargetRes> {
|
||||
return this.rpcRequest({ method: 'backup.target.cifs.update', params })
|
||||
}
|
||||
|
||||
async removeBackupTarget(
|
||||
params: RR.RemoveBackupTargetReq,
|
||||
): Promise<RR.RemoveBackupTargetRes> {
|
||||
return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
|
||||
}
|
||||
|
||||
async getBackupInfo(
|
||||
params: RR.GetBackupInfoReq,
|
||||
): Promise<RR.GetBackupInfoRes> {
|
||||
return this.rpcRequest({ method: 'backup.target.info', params })
|
||||
}
|
||||
|
||||
async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
|
||||
return this.rpcRequest({ method: 'backup.create', params })
|
||||
}
|
||||
|
||||
// package
|
||||
|
||||
async getPackageProperties(
|
||||
params: RR.GetPackagePropertiesReq,
|
||||
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
|
||||
return this.rpcRequest({ method: 'package.properties', params }).then(
|
||||
parsePropertiesPermissive,
|
||||
)
|
||||
}
|
||||
|
||||
async getPackageLogs(
|
||||
params: RR.GetPackageLogsReq,
|
||||
): Promise<RR.GetPackageLogsRes> {
|
||||
return this.rpcRequest({ method: 'package.logs', params })
|
||||
}
|
||||
|
||||
async followPackageLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'package.logs.follow', params })
|
||||
}
|
||||
|
||||
async getPkgMetrics(
|
||||
params: RR.GetPackageMetricsReq,
|
||||
): Promise<RR.GetPackageMetricsRes> {
|
||||
return this.rpcRequest({ method: 'package.metrics', params })
|
||||
}
|
||||
|
||||
async installPackage(
|
||||
params: RR.InstallPackageReq,
|
||||
): Promise<RR.InstallPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.install', params })
|
||||
}
|
||||
|
||||
async getPackageConfig(
|
||||
params: RR.GetPackageConfigReq,
|
||||
): Promise<RR.GetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.get', params })
|
||||
}
|
||||
|
||||
async drySetPackageConfig(
|
||||
params: RR.DrySetPackageConfigReq,
|
||||
): Promise<RR.DrySetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.set.dry', params })
|
||||
}
|
||||
|
||||
async setPackageConfig(
|
||||
params: RR.SetPackageConfigReq,
|
||||
): Promise<RR.SetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.set', params })
|
||||
}
|
||||
|
||||
async restorePackages(
|
||||
params: RR.RestorePackagesReq,
|
||||
): Promise<RR.RestorePackagesRes> {
|
||||
return this.rpcRequest({ method: 'package.backup.restore', params })
|
||||
}
|
||||
|
||||
async executePackageAction(
|
||||
params: RR.ExecutePackageActionReq,
|
||||
): Promise<RR.ExecutePackageActionRes> {
|
||||
return this.rpcRequest({ method: 'package.action', params })
|
||||
}
|
||||
|
||||
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.start', params })
|
||||
}
|
||||
|
||||
async restartPackage(
|
||||
params: RR.RestartPackageReq,
|
||||
): Promise<RR.RestartPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.restart', params })
|
||||
}
|
||||
|
||||
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.stop', params })
|
||||
}
|
||||
|
||||
async uninstallPackage(
|
||||
params: RR.UninstallPackageReq,
|
||||
): Promise<RR.UninstallPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.uninstall', params })
|
||||
}
|
||||
|
||||
async dryConfigureDependency(
|
||||
params: RR.DryConfigureDependencyReq,
|
||||
): Promise<RR.DryConfigureDependencyRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.dependency.configure.dry',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async sideloadPackage(
|
||||
params: RR.SideloadPackageReq,
|
||||
): Promise<RR.SideloadPacakgeRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.sideload',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
|
||||
const { location } = this.document.defaultView!
|
||||
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||
const host = location.host
|
||||
|
||||
config.url = `${protocol}://${host}/ws${config.url}`
|
||||
|
||||
return webSocket(config)
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(
|
||||
options: RPCOptions,
|
||||
urlOverride?: string,
|
||||
): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(options, urlOverride)
|
||||
const body = res.body
|
||||
|
||||
if (isRpcError(body)) {
|
||||
if (body.error.code === 34) {
|
||||
console.error('Unauthenticated, logging out')
|
||||
this.auth.setUnverified()
|
||||
}
|
||||
throw new RpcError(body.error)
|
||||
}
|
||||
|
||||
const patchSequence = res.headers.get('x-patch-sequence')
|
||||
if (patchSequence)
|
||||
await firstValueFrom(
|
||||
this.patch.cache$.pipe(
|
||||
filter(({ sequence }) => sequence >= Number(patchSequence)),
|
||||
),
|
||||
)
|
||||
|
||||
return body.result
|
||||
}
|
||||
|
||||
private async httpRequest<T>(opts: HttpOptions): Promise<T> {
|
||||
const res = await this.http.httpRequest<T>(opts)
|
||||
return res.body
|
||||
}
|
||||
}
|
||||
1127
web/projects/ui/src/app/services/api/embassy-mock-api.service.ts
Normal file
1127
web/projects/ui/src/app/services/api/embassy-mock-api.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
682
web/projects/ui/src/app/services/api/mock-patch.ts
Normal file
682
web/projects/ui/src/app/services/api/mock-patch.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
import {
|
||||
DataModel,
|
||||
DockerIoFormat,
|
||||
HealthResult,
|
||||
PackageMainStatus,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { Mock } from './api.fixures'
|
||||
import { BUILT_IN_WIDGETS } from '../../pages/widgets/built-in/widgets'
|
||||
|
||||
export const mockPatchData: DataModel = {
|
||||
ui: {
|
||||
name: `Matt's Server`,
|
||||
'ack-welcome': '1.0.0',
|
||||
theme: 'Dark',
|
||||
widgets: BUILT_IN_WIDGETS.filter(
|
||||
({ id }) =>
|
||||
id === 'favorites' ||
|
||||
id === 'health' ||
|
||||
id === 'network' ||
|
||||
id === 'metrics',
|
||||
),
|
||||
marketplace: {
|
||||
'selected-url': 'https://registry.start9.com/',
|
||||
'known-hosts': {
|
||||
'https://registry.start9.com/': {
|
||||
name: 'Start9 Registry',
|
||||
},
|
||||
'https://community-registry.start9.com/': {},
|
||||
'https://beta-registry.start9.com/': {
|
||||
name: 'Dark9',
|
||||
},
|
||||
},
|
||||
},
|
||||
dev: {},
|
||||
gaming: {
|
||||
snake: {
|
||||
'high-score': 0,
|
||||
},
|
||||
},
|
||||
'ack-instructions': {},
|
||||
},
|
||||
'server-info': {
|
||||
id: 'abcdefgh',
|
||||
version: '0.3.5',
|
||||
'last-backup': new Date(new Date().valueOf() - 604800001).toISOString(),
|
||||
'lan-address': 'https://adjective-noun.local',
|
||||
'tor-address': 'https://myveryownspecialtoraddress.onion',
|
||||
'ip-info': {
|
||||
eth0: {
|
||||
ipv4: '10.0.0.1',
|
||||
ipv6: null,
|
||||
},
|
||||
wlan0: {
|
||||
ipv4: '10.0.90.12',
|
||||
ipv6: 'FE80:CD00:0000:0CDE:1257:0000:211E:729CD',
|
||||
},
|
||||
},
|
||||
'last-wifi-region': null,
|
||||
'unread-notification-count': 4,
|
||||
// password is asdfasdf
|
||||
'password-hash':
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'eos-version-compat': '>=0.3.0 <=0.3.0.1',
|
||||
'status-info': {
|
||||
'backup-progress': null,
|
||||
updated: false,
|
||||
'update-progress': null,
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
},
|
||||
hostname: 'random-words',
|
||||
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||
'ca-fingerprint': 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
|
||||
'ntp-synced': false,
|
||||
zram: false,
|
||||
platform: 'x86_64-nonfree',
|
||||
},
|
||||
'package-data': {
|
||||
bitcoind: {
|
||||
state: PackageState.Installed,
|
||||
'static-files': {
|
||||
license: '/public/package-data/bitcoind/0.20.0/LICENSE.md',
|
||||
icon: '/assets/img/service-icons/bitcoind.svg',
|
||||
instructions: '/public/package-data/bitcoind/0.20.0/INSTRUCTIONS.md',
|
||||
},
|
||||
manifest: {
|
||||
id: 'bitcoind',
|
||||
title: 'Bitcoin Core',
|
||||
version: '0.20.0',
|
||||
'git-hash': 'abcdefgh',
|
||||
description: {
|
||||
short: 'A Bitcoin full node by Bitcoin Core.',
|
||||
long: 'Bitcoin is a decentralized consensus protocol and settlement network.',
|
||||
},
|
||||
'release-notes': 'Taproot, Schnorr, and more.',
|
||||
assets: {
|
||||
icon: 'icon.png',
|
||||
license: 'LICENSE.md',
|
||||
instructions: 'INSTRUCTIONS.md',
|
||||
docker_images: 'image.tar',
|
||||
assets: './assets',
|
||||
scripts: './scripts',
|
||||
},
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/start9labs/bitcoind-wrapper',
|
||||
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
|
||||
'support-site': 'https://bitcoin.org',
|
||||
'marketing-site': 'https://bitcoin.org',
|
||||
'donation-url': 'https://start9.com',
|
||||
alerts: {
|
||||
install: 'Bitcoin can take over a week to sync.',
|
||||
uninstall:
|
||||
'Chain state will be lost, as will any funds stored on your Bitcoin Core waller that have not been backed up.',
|
||||
restore: null,
|
||||
start: 'Starting Bitcoin is good for your health.',
|
||||
stop: null,
|
||||
},
|
||||
main: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': '.49m',
|
||||
},
|
||||
'health-checks': {
|
||||
'chain-state': {
|
||||
name: 'Chain State',
|
||||
},
|
||||
'ephemeral-health-check': {
|
||||
name: 'Ephemeral Health Check',
|
||||
},
|
||||
'p2p-interface': {
|
||||
name: 'P2P Interface',
|
||||
'success-message': 'the health check ran succesfully',
|
||||
},
|
||||
'rpc-interface': {
|
||||
name: 'RPC Interface',
|
||||
},
|
||||
'unnecessary-health-check': {
|
||||
name: 'Unneccessary Health Check',
|
||||
},
|
||||
} as any,
|
||||
config: {
|
||||
get: {},
|
||||
set: {},
|
||||
} as any,
|
||||
volumes: {},
|
||||
'min-os-version': '0.2.12',
|
||||
interfaces: {
|
||||
ui: {
|
||||
name: 'Node Visualizer',
|
||||
description:
|
||||
'Web application for viewing information about your node and the Bitcoin network.',
|
||||
ui: true,
|
||||
'tor-config': {
|
||||
'port-mapping': {},
|
||||
},
|
||||
'lan-config': {},
|
||||
protocols: [],
|
||||
},
|
||||
rpc: {
|
||||
name: 'RPC',
|
||||
description:
|
||||
'Used by wallets to interact with your Bitcoin Core node.',
|
||||
ui: false,
|
||||
'tor-config': {
|
||||
'port-mapping': {},
|
||||
},
|
||||
'lan-config': {},
|
||||
protocols: [],
|
||||
},
|
||||
p2p: {
|
||||
name: 'P2P',
|
||||
description:
|
||||
'Used by other Bitcoin nodes to communicate and interact with your node.',
|
||||
ui: false,
|
||||
'tor-config': {
|
||||
'port-mapping': {},
|
||||
},
|
||||
'lan-config': {},
|
||||
protocols: [],
|
||||
},
|
||||
},
|
||||
backup: {
|
||||
create: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
restore: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
},
|
||||
migrations: null,
|
||||
actions: {
|
||||
resync: {
|
||||
name: 'Resync Blockchain',
|
||||
description:
|
||||
'Use this to resync the Bitcoin blockchain from genesis',
|
||||
warning: 'This will take a couple of days.',
|
||||
'allowed-statuses': [
|
||||
PackageMainStatus.Running,
|
||||
PackageMainStatus.Stopped,
|
||||
],
|
||||
implementation: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
'input-spec': {
|
||||
reason: {
|
||||
type: 'string',
|
||||
name: 'Re-sync Reason',
|
||||
description:
|
||||
'Your reason for re-syncing. Why are you doing this?',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
pattern: '^[a-zA-Z]+$',
|
||||
'pattern-description': 'Must contain only letters.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
name: 'Your Name',
|
||||
description: 'Tell the class your name.',
|
||||
nullable: true,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
warning: 'You may loose all your money by providing your name.',
|
||||
},
|
||||
notifications: {
|
||||
name: 'Notification Preferences',
|
||||
type: 'list',
|
||||
subtype: 'enum',
|
||||
description: 'how you want to be notified',
|
||||
range: '[1,3]',
|
||||
default: ['email'],
|
||||
spec: {
|
||||
'value-names': {
|
||||
email: 'Email',
|
||||
text: 'Text',
|
||||
call: 'Call',
|
||||
push: 'Push',
|
||||
webhook: 'Webhook',
|
||||
},
|
||||
values: ['email', 'text', 'call', 'push', 'webhook'],
|
||||
},
|
||||
},
|
||||
'days-ago': {
|
||||
type: 'number',
|
||||
name: 'Days Ago',
|
||||
description: 'Number of days to re-sync.',
|
||||
nullable: false,
|
||||
default: 100,
|
||||
range: '[0, 9999]',
|
||||
integral: true,
|
||||
},
|
||||
'top-speed': {
|
||||
type: 'number',
|
||||
name: 'Top Speed',
|
||||
description: 'The fastest you can possibly run.',
|
||||
nullable: false,
|
||||
range: '[-1000, 1000]',
|
||||
integral: false,
|
||||
units: 'm/s',
|
||||
},
|
||||
testnet: {
|
||||
name: 'Testnet',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'<ul><li>determines whether your node is running on testnet or mainnet</li></ul><script src="fake"></script>',
|
||||
warning: 'Chain will have to resync!',
|
||||
default: false,
|
||||
},
|
||||
randomEnum: {
|
||||
name: 'Random Enum',
|
||||
type: 'enum',
|
||||
'value-names': {
|
||||
null: 'Null',
|
||||
good: 'Good',
|
||||
bad: 'Bad',
|
||||
ugly: 'Ugly',
|
||||
},
|
||||
default: 'null',
|
||||
description: 'This is not even real.',
|
||||
warning: 'Be careful changing this!',
|
||||
values: ['null', 'good', 'bad', 'ugly'],
|
||||
},
|
||||
'emergency-contact': {
|
||||
name: 'Emergency Contact',
|
||||
type: 'object',
|
||||
description: 'The person to contact in case of emergency.',
|
||||
spec: {
|
||||
name: {
|
||||
type: 'string',
|
||||
name: 'Name',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
pattern: '^[a-zA-Z]+$',
|
||||
'pattern-description': 'Must contain only letters.',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
name: 'Email',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ips: {
|
||||
name: 'Whitelist IPs',
|
||||
type: 'list',
|
||||
subtype: 'string',
|
||||
description:
|
||||
'external ip addresses that are authorized to access your Bitcoin node',
|
||||
warning:
|
||||
'Any IP you allow here will have RPC access to your Bitcoin node.',
|
||||
range: '[1,10]',
|
||||
default: ['192.168.1.1'],
|
||||
spec: {
|
||||
pattern: '^[0-9]{1,3}([,.][0-9]{1,3})?$',
|
||||
'pattern-description': 'Must be a valid IP address',
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
},
|
||||
bitcoinNode: {
|
||||
type: 'union',
|
||||
default: 'internal',
|
||||
tag: {
|
||||
id: 'type',
|
||||
'variant-names': {
|
||||
internal: 'Internal',
|
||||
external: 'External',
|
||||
},
|
||||
name: 'Bitcoin Node Settings',
|
||||
description: 'The node settings',
|
||||
warning: 'Careful changing this',
|
||||
},
|
||||
variants: {
|
||||
internal: {
|
||||
'lan-address': {
|
||||
name: 'LAN Address',
|
||||
type: 'pointer',
|
||||
subtype: 'package',
|
||||
target: 'lan-address',
|
||||
'package-id': 'bitcoind',
|
||||
description: 'the lan address',
|
||||
interface: '',
|
||||
},
|
||||
'friendly-name': {
|
||||
name: 'Friendly Name',
|
||||
type: 'string',
|
||||
description: 'the lan address',
|
||||
nullable: true,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
},
|
||||
external: {
|
||||
'public-domain': {
|
||||
name: 'Public Domain',
|
||||
type: 'string',
|
||||
description: 'the public address of the node',
|
||||
nullable: false,
|
||||
default: 'bitcoinnode.com',
|
||||
pattern: '.*',
|
||||
'pattern-description': 'anything',
|
||||
masked: false,
|
||||
copyable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies: {},
|
||||
},
|
||||
installed: {
|
||||
manifest: {
|
||||
...Mock.MockManifestBitcoind,
|
||||
version: '0.20.0',
|
||||
},
|
||||
'last-backup': null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: PackageMainStatus.Running,
|
||||
started: '2021-06-14T20:49:17.774Z',
|
||||
health: {
|
||||
'ephemeral-health-check': {
|
||||
result: HealthResult.Starting,
|
||||
},
|
||||
'chain-state': {
|
||||
result: HealthResult.Loading,
|
||||
message: 'Bitcoin is syncing from genesis',
|
||||
},
|
||||
'p2p-interface': {
|
||||
result: HealthResult.Success,
|
||||
},
|
||||
'rpc-interface': {
|
||||
result: HealthResult.Failure,
|
||||
error: 'RPC interface unreachable.',
|
||||
},
|
||||
'unnecessary-health-check': {
|
||||
result: HealthResult.Disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
'dependency-config-errors': {},
|
||||
},
|
||||
'interface-addresses': {
|
||||
ui: {
|
||||
'tor-address': 'bitcoind-ui-address.onion',
|
||||
'lan-address': 'bitcoind-ui-address.local',
|
||||
},
|
||||
rpc: {
|
||||
'tor-address': 'bitcoind-rpc-address.onion',
|
||||
'lan-address': 'bitcoind-rpc-address.local',
|
||||
},
|
||||
p2p: {
|
||||
'tor-address': 'bitcoind-p2p-address.onion',
|
||||
'lan-address': 'bitcoind-p2p-address.local',
|
||||
},
|
||||
},
|
||||
'system-pointers': [],
|
||||
'current-dependents': {
|
||||
lnd: {
|
||||
pointers: [],
|
||||
'health-checks': [],
|
||||
},
|
||||
},
|
||||
'current-dependencies': {},
|
||||
'dependency-info': {},
|
||||
'marketplace-url': 'https://registry.start9.com/',
|
||||
'developer-key': 'developer-key',
|
||||
},
|
||||
},
|
||||
lnd: {
|
||||
state: PackageState.Installed,
|
||||
'static-files': {
|
||||
license: '/public/package-data/lnd/0.11.1/LICENSE.md',
|
||||
icon: '/assets/img/service-icons/lnd.png',
|
||||
instructions: '/public/package-data/lnd/0.11.1/INSTRUCTIONS.md',
|
||||
},
|
||||
manifest: {
|
||||
id: 'lnd',
|
||||
title: 'Lightning Network Daemon',
|
||||
version: '0.11.0',
|
||||
description: {
|
||||
short: 'A bolt spec compliant client.',
|
||||
long: 'More info about LND. More info about LND. More info about LND.',
|
||||
},
|
||||
'release-notes': 'Dual funded channels!',
|
||||
assets: {
|
||||
icon: 'icon.png',
|
||||
license: 'LICENSE.md',
|
||||
instructions: 'INSTRUCTIONS.md',
|
||||
docker_images: 'image.tar',
|
||||
assets: './assets',
|
||||
scripts: './scripts',
|
||||
},
|
||||
license: 'MIT',
|
||||
'wrapper-repo': 'https://github.com/start9labs/lnd-wrapper',
|
||||
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
|
||||
'support-site': 'https://lightning.engineering/',
|
||||
'marketing-site': 'https://lightning.engineering/',
|
||||
'donation-url': null,
|
||||
alerts: {
|
||||
install: null,
|
||||
uninstall: null,
|
||||
restore:
|
||||
'If this is a duplicate instance of the same LND node, you may loose your funds.',
|
||||
start: 'Starting LND is good for your health.',
|
||||
stop: null,
|
||||
},
|
||||
main: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': '0.5s',
|
||||
},
|
||||
'health-checks': {},
|
||||
config: {
|
||||
get: null,
|
||||
set: null,
|
||||
},
|
||||
volumes: {},
|
||||
'min-os-version': '0.2.12',
|
||||
interfaces: {
|
||||
rpc: {
|
||||
name: 'RPC interface',
|
||||
description: 'Good for connecting to your node at a distance.',
|
||||
ui: true,
|
||||
'tor-config': {
|
||||
'port-mapping': {},
|
||||
},
|
||||
'lan-config': {
|
||||
'44': {
|
||||
ssl: true,
|
||||
mapping: 33,
|
||||
},
|
||||
},
|
||||
protocols: [],
|
||||
},
|
||||
grpc: {
|
||||
name: 'GRPC',
|
||||
description: 'Certain wallet use grpc.',
|
||||
ui: false,
|
||||
'tor-config': {
|
||||
'port-mapping': {},
|
||||
},
|
||||
'lan-config': {
|
||||
'66': {
|
||||
ssl: true,
|
||||
mapping: 55,
|
||||
},
|
||||
},
|
||||
protocols: [],
|
||||
},
|
||||
},
|
||||
backup: {
|
||||
create: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
restore: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
},
|
||||
migrations: null,
|
||||
actions: {
|
||||
resync: {
|
||||
name: 'Resync Network Graph',
|
||||
description: 'Your node will resync its network graph.',
|
||||
warning: 'This will take a couple hours.',
|
||||
'allowed-statuses': [PackageMainStatus.Running],
|
||||
implementation: {
|
||||
type: 'docker',
|
||||
image: '',
|
||||
system: true,
|
||||
entrypoint: '',
|
||||
args: [],
|
||||
mounts: {},
|
||||
'io-format': DockerIoFormat.Yaml,
|
||||
inject: false,
|
||||
'shm-size': '',
|
||||
'sigterm-timeout': null,
|
||||
},
|
||||
'input-spec': null,
|
||||
},
|
||||
},
|
||||
dependencies: {
|
||||
bitcoind: {
|
||||
version: '=0.21.0',
|
||||
description: 'LND needs bitcoin to live.',
|
||||
requirement: {
|
||||
type: 'opt-out',
|
||||
how: 'You can use an external node from your server if you prefer.',
|
||||
},
|
||||
config: null,
|
||||
},
|
||||
'btc-rpc-proxy': {
|
||||
version: '>=0.2.2',
|
||||
description:
|
||||
'As long as Bitcoin is pruned, LND needs Bitcoin Proxy to fetch block over the P2P network.',
|
||||
requirement: {
|
||||
type: 'opt-in',
|
||||
how: `To use Proxy's user management system, go to LND config and select Bitcoin Proxy under Bitcoin config.`,
|
||||
},
|
||||
config: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
installed: {
|
||||
manifest: {
|
||||
...Mock.MockManifestLnd,
|
||||
version: '0.11.0',
|
||||
},
|
||||
'last-backup': null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: PackageMainStatus.Stopped,
|
||||
},
|
||||
'dependency-config-errors': {
|
||||
'btc-rpc-proxy': 'This is a config unsatisfied error',
|
||||
},
|
||||
},
|
||||
'interface-addresses': {
|
||||
rpc: {
|
||||
'tor-address': 'lnd-rpc-address.onion',
|
||||
'lan-address': 'lnd-rpc-address.local',
|
||||
},
|
||||
grpc: {
|
||||
'tor-address': 'lnd-grpc-address.onion',
|
||||
'lan-address': 'lnd-grpc-address.local',
|
||||
},
|
||||
},
|
||||
'system-pointers': [],
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {
|
||||
bitcoind: {
|
||||
pointers: [],
|
||||
'health-checks': [],
|
||||
},
|
||||
'btc-rpc-proxy': {
|
||||
pointers: [],
|
||||
'health-checks': [],
|
||||
},
|
||||
},
|
||||
'dependency-info': {
|
||||
bitcoind: {
|
||||
title: 'Bitcoin Core',
|
||||
icon: 'assets/img/service-icons/bitcoind.svg',
|
||||
},
|
||||
'btc-rpc-proxy': {
|
||||
title: 'Bitcoin Proxy',
|
||||
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
|
||||
},
|
||||
},
|
||||
'marketplace-url': 'https://registry.start9.com/',
|
||||
'developer-key': 'developer-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
50
web/projects/ui/src/app/services/auth.service.ts
Normal file
50
web/projects/ui/src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable, NgZone } from '@angular/core'
|
||||
import { ReplaySubject } from 'rxjs'
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators'
|
||||
import { Router } from '@angular/router'
|
||||
import { StorageService } from './storage.service'
|
||||
|
||||
export enum AuthState {
|
||||
UNVERIFIED,
|
||||
VERIFIED,
|
||||
}
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly LOGGED_IN_KEY = 'loggedInKey'
|
||||
private readonly authState$ = new ReplaySubject<AuthState>(1)
|
||||
|
||||
readonly isVerified$ = this.authState$.pipe(
|
||||
map(state => state === AuthState.VERIFIED),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageService,
|
||||
private readonly zone: NgZone,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
init(): void {
|
||||
const loggedIn = this.storage.get(this.LOGGED_IN_KEY)
|
||||
if (loggedIn) {
|
||||
this.setVerified()
|
||||
} else {
|
||||
this.setUnverified()
|
||||
}
|
||||
}
|
||||
|
||||
setVerified(): void {
|
||||
this.storage.set(this.LOGGED_IN_KEY, true)
|
||||
this.authState$.next(AuthState.VERIFIED)
|
||||
}
|
||||
|
||||
setUnverified(): void {
|
||||
this.authState$.next(AuthState.UNVERIFIED)
|
||||
this.storage.clear()
|
||||
this.zone.run(() => {
|
||||
this.router.navigate(['/login'], { replaceUrl: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
61
web/projects/ui/src/app/services/client-storage.service.ts
Normal file
61
web/projects/ui/src/app/services/client-storage.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
145
web/projects/ui/src/app/services/config.service.ts
Normal file
145
web/projects/ui/src/app/services/config.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { WorkspaceConfig } from '@start9labs/shared'
|
||||
import {
|
||||
InterfaceDef,
|
||||
PackageDataEntry,
|
||||
PackageMainStatus,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
const {
|
||||
gitHash,
|
||||
useMocks,
|
||||
ui: { api, marketplace, mocks },
|
||||
} = require('../../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigService {
|
||||
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||
|
||||
hostname = this.document.location.hostname
|
||||
// includes port
|
||||
host = this.document.location.host
|
||||
// includes ":" (e.g. "http:")
|
||||
protocol = this.document.location.protocol
|
||||
version = require('../../../../../package.json').version as string
|
||||
useMocks = useMocks
|
||||
mocks = mocks
|
||||
gitHash = gitHash
|
||||
api = api
|
||||
marketplace = marketplace
|
||||
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
|
||||
isConsulate = (window as any)['platform'] === 'ios'
|
||||
supportsWebSockets = !!window.WebSocket || this.isConsulate
|
||||
|
||||
isTor(): boolean {
|
||||
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
|
||||
}
|
||||
|
||||
isLocal(): boolean {
|
||||
return useMocks
|
||||
? mocks.maskAs === 'local'
|
||||
: this.hostname.endsWith('.local')
|
||||
}
|
||||
|
||||
isTorHttp(): boolean {
|
||||
return this.isTor() && !this.isHttps()
|
||||
}
|
||||
|
||||
isLanHttp(): boolean {
|
||||
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
|
||||
}
|
||||
|
||||
isSecure(): boolean {
|
||||
return window.isSecureContext || this.isTor()
|
||||
}
|
||||
|
||||
isLaunchable(
|
||||
state: PackageState,
|
||||
status: PackageMainStatus,
|
||||
interfaces: Record<string, InterfaceDef>,
|
||||
): boolean {
|
||||
return (
|
||||
state === PackageState.Installed &&
|
||||
status === PackageMainStatus.Running &&
|
||||
hasUi(interfaces)
|
||||
)
|
||||
}
|
||||
|
||||
launchableURL(pkg: PackageDataEntry): string {
|
||||
if (!this.isTor() && hasLocalUi(pkg.manifest.interfaces)) {
|
||||
return `https://${lanUiAddress(pkg)}`
|
||||
} else {
|
||||
return `http://${torUiAddress(pkg)}`
|
||||
}
|
||||
}
|
||||
|
||||
getHost(): string {
|
||||
return this.host
|
||||
}
|
||||
|
||||
private isLocalhost(): boolean {
|
||||
return useMocks
|
||||
? mocks.maskAs === 'localhost'
|
||||
: this.hostname === 'localhost'
|
||||
}
|
||||
|
||||
private isHttps(): boolean {
|
||||
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean {
|
||||
const int = getUiInterfaceValue(interfaces)
|
||||
return !!int?.['tor-config']
|
||||
}
|
||||
|
||||
export function hasLocalUi(interfaces: Record<string, InterfaceDef>): boolean {
|
||||
const int = getUiInterfaceValue(interfaces)
|
||||
return !!int?.['lan-config']
|
||||
}
|
||||
|
||||
export function torUiAddress({
|
||||
manifest,
|
||||
installed,
|
||||
}: PackageDataEntry): string {
|
||||
const key = getUiInterfaceKey(manifest.interfaces)
|
||||
return installed ? installed['interface-addresses'][key]['tor-address'] : ''
|
||||
}
|
||||
|
||||
export function lanUiAddress({
|
||||
manifest,
|
||||
installed,
|
||||
}: PackageDataEntry): string {
|
||||
const key = getUiInterfaceKey(manifest.interfaces)
|
||||
return installed ? installed['interface-addresses'][key]['lan-address'] : ''
|
||||
}
|
||||
|
||||
export function hasUi(interfaces: Record<string, InterfaceDef>): boolean {
|
||||
return hasTorUi(interfaces) || hasLocalUi(interfaces)
|
||||
}
|
||||
|
||||
export function removeProtocol(str: string): string {
|
||||
if (str.startsWith('http://')) return str.slice(7)
|
||||
if (str.startsWith('https://')) return str.slice(8)
|
||||
return str
|
||||
}
|
||||
|
||||
export function removePort(str: string): string {
|
||||
return str.split(':')[0]
|
||||
}
|
||||
|
||||
export function getUiInterfaceKey(
|
||||
interfaces: Record<string, InterfaceDef>,
|
||||
): string {
|
||||
return Object.keys(interfaces).find(key => interfaces[key].ui) || ''
|
||||
}
|
||||
|
||||
export function getUiInterfaceValue(
|
||||
interfaces: Record<string, InterfaceDef>,
|
||||
): InterfaceDef | null {
|
||||
return Object.values(interfaces).find(i => i.ui) || null
|
||||
}
|
||||
25
web/projects/ui/src/app/services/connection.service.ts
Normal file
25
web/projects/ui/src/app/services/connection.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConnectionService {
|
||||
readonly networkConnected$ = merge(
|
||||
fromEvent(window, 'online'),
|
||||
fromEvent(window, 'offline'),
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => navigator.onLine),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
|
||||
readonly connected$ = combineLatest([
|
||||
this.networkConnected$,
|
||||
this.websocketConnected$.pipe(distinctUntilChanged()),
|
||||
]).pipe(
|
||||
map(([network, websocket]) => network && websocket),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
}
|
||||
214
web/projects/ui/src/app/services/dep-error.service.ts
Normal file
214
web/projects/ui/src/app/services/dep-error.service.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
DataModel,
|
||||
HealthCheckResult,
|
||||
HealthResult,
|
||||
InstalledPackageDataEntry,
|
||||
PackageMainStatus,
|
||||
} from './patch-db/data-model'
|
||||
import * as deepEqual from 'fast-deep-equal'
|
||||
|
||||
export type AllDependencyErrors = Record<string, PkgDependencyErrors>
|
||||
export type PkgDependencyErrors = Record<string, DependencyError | null>
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DepErrorService {
|
||||
readonly depErrors$ = this.patch.watch$('package-data').pipe(
|
||||
map(pkgs =>
|
||||
Object.keys(pkgs)
|
||||
.map(id => ({
|
||||
id,
|
||||
depth: dependencyDepth(pkgs, id),
|
||||
}))
|
||||
.sort((a, b) => (b.depth > a.depth ? -1 : 1))
|
||||
.reduce(
|
||||
(errors, { id }): AllDependencyErrors => ({
|
||||
...errors,
|
||||
[id]: this.getDepErrors(pkgs, id, errors),
|
||||
}),
|
||||
{} as AllDependencyErrors,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(deepEqual),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly emver: Emver,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
getPkgDepErrors$(pkgId: string) {
|
||||
return this.depErrors$.pipe(
|
||||
map(depErrors => depErrors[pkgId]),
|
||||
distinctUntilChanged(deepEqual),
|
||||
)
|
||||
}
|
||||
|
||||
private getDepErrors(
|
||||
pkgs: DataModel['package-data'],
|
||||
pkgId: string,
|
||||
outerErrors: AllDependencyErrors,
|
||||
): PkgDependencyErrors {
|
||||
const pkgInstalled = pkgs[pkgId].installed
|
||||
|
||||
if (!pkgInstalled) return {}
|
||||
|
||||
return currentDeps(pkgs, pkgId).reduce(
|
||||
(innerErrors, depId): PkgDependencyErrors => ({
|
||||
...innerErrors,
|
||||
[depId]: this.getDepError(pkgs, pkgInstalled, depId, outerErrors),
|
||||
}),
|
||||
{} as PkgDependencyErrors,
|
||||
)
|
||||
}
|
||||
|
||||
private getDepError(
|
||||
pkgs: DataModel['package-data'],
|
||||
pkgInstalled: InstalledPackageDataEntry,
|
||||
depId: string,
|
||||
outerErrors: AllDependencyErrors,
|
||||
): DependencyError | null {
|
||||
const depInstalled = pkgs[depId]?.installed
|
||||
|
||||
// not installed
|
||||
if (!depInstalled) {
|
||||
return {
|
||||
type: DependencyErrorType.NotInstalled,
|
||||
}
|
||||
}
|
||||
|
||||
const pkgManifest = pkgInstalled.manifest
|
||||
const depManifest = depInstalled.manifest
|
||||
|
||||
// incorrect version
|
||||
if (
|
||||
!this.emver.satisfies(
|
||||
depManifest.version,
|
||||
pkgManifest.dependencies[depId].version,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: DependencyErrorType.IncorrectVersion,
|
||||
expected: pkgManifest.dependencies[depId].version,
|
||||
received: depManifest.version,
|
||||
}
|
||||
}
|
||||
|
||||
// invalid config
|
||||
if (
|
||||
Object.values(pkgInstalled.status['dependency-config-errors']).some(
|
||||
err => !!err,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: DependencyErrorType.ConfigUnsatisfied,
|
||||
}
|
||||
}
|
||||
|
||||
const depStatus = depInstalled.status.main.status
|
||||
|
||||
// not running
|
||||
if (
|
||||
depStatus !== PackageMainStatus.Running &&
|
||||
depStatus !== PackageMainStatus.Starting
|
||||
) {
|
||||
return {
|
||||
type: DependencyErrorType.NotRunning,
|
||||
}
|
||||
}
|
||||
|
||||
// health check failure
|
||||
if (depStatus === PackageMainStatus.Running) {
|
||||
for (let id of pkgInstalled['current-dependencies'][depId][
|
||||
'health-checks'
|
||||
]) {
|
||||
if (
|
||||
depInstalled.status.main.health[id]?.result !== HealthResult.Success
|
||||
) {
|
||||
return {
|
||||
type: DependencyErrorType.HealthChecksFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// transitive
|
||||
const transitiveError = currentDeps(pkgs, depId).some(transitiveId =>
|
||||
Object.values(outerErrors[transitiveId]).some(err => !!err),
|
||||
)
|
||||
|
||||
if (transitiveError) {
|
||||
return {
|
||||
type: DependencyErrorType.Transitive,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function currentDeps(pkgs: DataModel['package-data'], id: string): string[] {
|
||||
return Object.keys(
|
||||
pkgs[id]?.installed?.['current-dependencies'] || {},
|
||||
).filter(depId => depId !== id)
|
||||
}
|
||||
|
||||
function dependencyDepth(
|
||||
pkgs: DataModel['package-data'],
|
||||
id: string,
|
||||
depth = 0,
|
||||
): number {
|
||||
return currentDeps(pkgs, id).reduce(
|
||||
(prev, depId) => dependencyDepth(pkgs, depId, prev + 1),
|
||||
depth,
|
||||
)
|
||||
}
|
||||
|
||||
export type DependencyError =
|
||||
| DependencyErrorNotInstalled
|
||||
| DependencyErrorNotRunning
|
||||
| DependencyErrorIncorrectVersion
|
||||
| DependencyErrorConfigUnsatisfied
|
||||
| DependencyErrorHealthChecksFailed
|
||||
| DependencyErrorTransitive
|
||||
|
||||
export enum DependencyErrorType {
|
||||
NotInstalled = 'notInstalled',
|
||||
NotRunning = 'notRunning',
|
||||
IncorrectVersion = 'incorrectVersion',
|
||||
ConfigUnsatisfied = 'configUnsatisfied',
|
||||
HealthChecksFailed = 'healthChecksFailed',
|
||||
Transitive = 'transitive',
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotInstalled {
|
||||
type: DependencyErrorType.NotInstalled
|
||||
}
|
||||
|
||||
export interface DependencyErrorNotRunning {
|
||||
type: DependencyErrorType.NotRunning
|
||||
}
|
||||
|
||||
export interface DependencyErrorIncorrectVersion {
|
||||
type: DependencyErrorType.IncorrectVersion
|
||||
expected: string // version range
|
||||
received: string // version
|
||||
}
|
||||
|
||||
export interface DependencyErrorConfigUnsatisfied {
|
||||
type: DependencyErrorType.ConfigUnsatisfied
|
||||
}
|
||||
|
||||
export interface DependencyErrorHealthChecksFailed {
|
||||
type: DependencyErrorType.HealthChecksFailed
|
||||
}
|
||||
|
||||
export interface DependencyErrorTransitive {
|
||||
type: DependencyErrorType.Transitive
|
||||
}
|
||||
60
web/projects/ui/src/app/services/eos.service.ts
Normal file
60
web/projects/ui/src/app/services/eos.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs'
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators'
|
||||
import { MarketplaceEOS } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EOSService {
|
||||
eos?: MarketplaceEOS
|
||||
updateAvailable$ = new BehaviorSubject<boolean>(false)
|
||||
|
||||
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
|
||||
map(status => !!status['update-progress'] || status.updated),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
readonly backingUp$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'backup-progress')
|
||||
.pipe(
|
||||
map(obj => !!obj),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
readonly updatingOrBackingUp$ = combineLatest([
|
||||
this.updating$,
|
||||
this.backingUp$,
|
||||
]).pipe(
|
||||
map(([updating, backingUp]) => {
|
||||
return updating || backingUp
|
||||
}),
|
||||
)
|
||||
|
||||
readonly showUpdate$ = combineLatest([
|
||||
this.updateAvailable$,
|
||||
this.updating$,
|
||||
]).pipe(
|
||||
map(([available, updating]) => {
|
||||
return available && !updating
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly emver: Emver,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async loadEos(): Promise<void> {
|
||||
const { version } = await getServerInfo(this.patch)
|
||||
this.eos = await this.api.getEos()
|
||||
const updateAvailable = this.emver.compare(this.eos.version, version) === 1
|
||||
this.updateAvailable$.next(updateAvailable)
|
||||
}
|
||||
}
|
||||
563
web/projects/ui/src/app/services/form.service.ts
Normal file
563
web/projects/ui/src/app/services/form.service.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
UntypedFormArray,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import {
|
||||
ConfigSpec,
|
||||
isValueSpecListOf,
|
||||
ListValueSpecNumber,
|
||||
ListValueSpecObject,
|
||||
ListValueSpecOf,
|
||||
ListValueSpecString,
|
||||
ListValueSpecUnion,
|
||||
UniqueBy,
|
||||
ValueSpec,
|
||||
ValueSpecEnum,
|
||||
ValueSpecList,
|
||||
ValueSpecNumber,
|
||||
ValueSpecObject,
|
||||
ValueSpecString,
|
||||
ValueSpecUnion,
|
||||
} from 'src/app/pkg-config/config-types'
|
||||
import { getDefaultString, Range } from '../pkg-config/config-utilities'
|
||||
const Mustache = require('mustache')
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FormService {
|
||||
constructor(private readonly formBuilder: UntypedFormBuilder) {}
|
||||
|
||||
createForm(
|
||||
spec: ConfigSpec,
|
||||
current: { [key: string]: any } = {},
|
||||
): UntypedFormGroup {
|
||||
return this.getFormGroup(spec, [], current)
|
||||
}
|
||||
|
||||
getUnionObject(
|
||||
spec: ValueSpecUnion | ListValueSpecUnion,
|
||||
selection: string,
|
||||
current?: { [key: string]: any } | null,
|
||||
): UntypedFormGroup {
|
||||
const { variants, tag } = spec
|
||||
const { name, description, warning, 'variant-names': variantNames } = tag
|
||||
|
||||
const enumSpec: ValueSpecEnum = {
|
||||
type: 'enum',
|
||||
name,
|
||||
description,
|
||||
warning,
|
||||
default: selection,
|
||||
values: Object.keys(variants),
|
||||
'value-names': variantNames,
|
||||
}
|
||||
return this.getFormGroup(
|
||||
{ [spec.tag.id]: enumSpec, ...spec.variants[selection] },
|
||||
[],
|
||||
current,
|
||||
)
|
||||
}
|
||||
|
||||
getListItem(spec: ValueSpecList, entry: any) {
|
||||
const listItemValidators = getListItemValidators(spec)
|
||||
if (isValueSpecListOf(spec, 'string')) {
|
||||
return this.formBuilder.control(entry, listItemValidators)
|
||||
} else if (isValueSpecListOf(spec, 'number')) {
|
||||
return this.formBuilder.control(entry, listItemValidators)
|
||||
} else if (isValueSpecListOf(spec, 'enum')) {
|
||||
return this.formBuilder.control(entry)
|
||||
} else if (isValueSpecListOf(spec, 'object')) {
|
||||
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
|
||||
} else if (isValueSpecListOf(spec, 'union')) {
|
||||
return this.getUnionObject(spec.spec, spec.spec.default, entry)
|
||||
}
|
||||
}
|
||||
|
||||
private getFormGroup(
|
||||
config: ConfigSpec,
|
||||
validators: ValidatorFn[] = [],
|
||||
current?: { [key: string]: any } | null,
|
||||
): UntypedFormGroup {
|
||||
let group: Record<
|
||||
string,
|
||||
UntypedFormGroup | UntypedFormArray | UntypedFormControl
|
||||
> = {}
|
||||
Object.entries(config).map(([key, spec]) => {
|
||||
if (spec.type === 'pointer') return
|
||||
group[key] = this.getFormEntry(spec, current ? current[key] : undefined)
|
||||
})
|
||||
return this.formBuilder.group(group, { validators })
|
||||
}
|
||||
|
||||
private getFormEntry(
|
||||
spec: ValueSpec,
|
||||
currentValue?: any,
|
||||
): UntypedFormGroup | UntypedFormArray | UntypedFormControl {
|
||||
let validators: ValidatorFn[]
|
||||
let value: any
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
validators = stringValidators(spec)
|
||||
if (currentValue !== undefined) {
|
||||
value = currentValue
|
||||
} else {
|
||||
value = spec.default ? getDefaultString(spec.default) : null
|
||||
}
|
||||
return this.formBuilder.control(value, validators)
|
||||
case 'number':
|
||||
validators = numberValidators(spec)
|
||||
if (currentValue !== undefined) {
|
||||
value = currentValue
|
||||
} else {
|
||||
value = spec.default || null
|
||||
}
|
||||
return this.formBuilder.control(value, validators)
|
||||
case 'object':
|
||||
return this.getFormGroup(spec.spec, [], currentValue)
|
||||
case 'list':
|
||||
validators = listValidators(spec)
|
||||
const mapped = (
|
||||
Array.isArray(currentValue) ? currentValue : (spec.default as any[])
|
||||
).map(entry => {
|
||||
return this.getListItem(spec, entry)
|
||||
})
|
||||
return this.formBuilder.array(mapped, validators)
|
||||
case 'union':
|
||||
const currentSelection = currentValue?.[spec.tag.id]
|
||||
const isValid = !!spec.variants[currentSelection]
|
||||
|
||||
return this.getUnionObject(
|
||||
spec,
|
||||
isValid ? currentSelection : spec.default,
|
||||
isValid ? currentValue : undefined,
|
||||
)
|
||||
case 'boolean':
|
||||
case 'enum':
|
||||
value = currentValue === undefined ? spec.default : currentValue
|
||||
return this.formBuilder.control(value)
|
||||
default:
|
||||
return this.formBuilder.control(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getListItemValidators(spec: ValueSpecList) {
|
||||
if (isValueSpecListOf(spec, 'string')) {
|
||||
return stringValidators(spec.spec)
|
||||
} else if (isValueSpecListOf(spec, 'number')) {
|
||||
return numberValidators(spec.spec)
|
||||
}
|
||||
}
|
||||
|
||||
function stringValidators(
|
||||
spec: ValueSpecString | ListValueSpecString,
|
||||
): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (!(spec as ValueSpecString).nullable) {
|
||||
validators.push(Validators.required)
|
||||
}
|
||||
|
||||
if (spec.pattern) {
|
||||
validators.push(Validators.pattern(spec.pattern))
|
||||
}
|
||||
|
||||
return validators
|
||||
}
|
||||
|
||||
function numberValidators(
|
||||
spec: ValueSpecNumber | ListValueSpecNumber,
|
||||
): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
validators.push(isNumber())
|
||||
|
||||
if (!(spec as ValueSpecNumber).nullable) {
|
||||
validators.push(Validators.required)
|
||||
}
|
||||
|
||||
if (spec.integral) {
|
||||
validators.push(isInteger())
|
||||
}
|
||||
|
||||
validators.push(numberInRange(spec.range))
|
||||
|
||||
return validators
|
||||
}
|
||||
|
||||
function listValidators(spec: ValueSpecList): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
validators.push(listInRange(spec.range))
|
||||
|
||||
validators.push(listItemIssue())
|
||||
|
||||
if (!isValueSpecListOf(spec, 'enum')) {
|
||||
validators.push(listUnique(spec))
|
||||
}
|
||||
|
||||
return validators
|
||||
}
|
||||
|
||||
export function numberInRange(stringRange: string): ValidatorFn {
|
||||
return control => {
|
||||
const value = control.value
|
||||
if (!value) return null
|
||||
try {
|
||||
Range.from(stringRange).checkIncludes(value)
|
||||
return null
|
||||
} catch (e: any) {
|
||||
return { numberNotInRange: { value: `Number must be ${e.message}` } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isNumber(): ValidatorFn {
|
||||
return control =>
|
||||
!control.value || control.value == Number(control.value)
|
||||
? null
|
||||
: { notNumber: { value: control.value } }
|
||||
}
|
||||
|
||||
export function isInteger(): ValidatorFn {
|
||||
return control =>
|
||||
!control.value || control.value == Math.trunc(control.value)
|
||||
? null
|
||||
: { numberNotInteger: { value: control.value } }
|
||||
}
|
||||
|
||||
export function listInRange(stringRange: string): ValidatorFn {
|
||||
return control => {
|
||||
try {
|
||||
Range.from(stringRange).checkIncludes(control.value.length)
|
||||
return null
|
||||
} catch (e: any) {
|
||||
return { listNotInRange: { value: `List must be ${e.message}` } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listItemIssue(): ValidatorFn {
|
||||
return parentControl => {
|
||||
const { controls } = parentControl as UntypedFormArray
|
||||
const problemChild = controls.find(c => c.invalid)
|
||||
if (problemChild) {
|
||||
return { listItemIssue: { value: 'Invalid entries' } }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listUnique(spec: ValueSpecList): ValidatorFn {
|
||||
return control => {
|
||||
const list = control.value
|
||||
for (let idx = 0; idx < list.length; idx++) {
|
||||
for (let idx2 = idx + 1; idx2 < list.length; idx2++) {
|
||||
if (listItemEquals(spec, list[idx], list[idx2])) {
|
||||
let display1: string
|
||||
let display2: string
|
||||
let uniqueMessage = isObjectOrUnion(spec.spec)
|
||||
? uniqueByMessageWrapper(
|
||||
spec.spec['unique-by'],
|
||||
spec.spec,
|
||||
list[idx],
|
||||
)
|
||||
: ''
|
||||
|
||||
if (isObjectOrUnion(spec.spec) && spec.spec['display-as']) {
|
||||
display1 = `"${(Mustache as any).render(
|
||||
spec.spec['display-as'],
|
||||
list[idx],
|
||||
)}"`
|
||||
display2 = `"${(Mustache as any).render(
|
||||
spec.spec['display-as'],
|
||||
list[idx2],
|
||||
)}"`
|
||||
} else {
|
||||
display1 = `Entry ${idx + 1}`
|
||||
display2 = `Entry ${idx2 + 1}`
|
||||
}
|
||||
|
||||
return {
|
||||
listNotUnique: {
|
||||
value: `${display1} and ${display2} are not unique.${uniqueMessage}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean {
|
||||
// TODO: fix types
|
||||
switch (spec.subtype) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'enum':
|
||||
return val1 == val2
|
||||
case 'object':
|
||||
const obj: ListValueSpecObject = spec.spec as any
|
||||
|
||||
return listObjEquals(obj['unique-by'], obj, val1, val2)
|
||||
case 'union':
|
||||
const union: ListValueSpecUnion = spec.spec as any
|
||||
|
||||
return unionEquals(union['unique-by'], union, val1, val2)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function itemEquals(spec: ValueSpec, val1: any, val2: any): boolean {
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'enum':
|
||||
return val1 == val2
|
||||
case 'object':
|
||||
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types
|
||||
return objEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as ValueSpecObject,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
case 'union':
|
||||
// TODO: 'unique-by' does not exist on ValueSpecUnion, fix types
|
||||
return unionEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as ValueSpecUnion,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
case 'list':
|
||||
if (val1.length !== val2.length) {
|
||||
return false
|
||||
}
|
||||
for (let idx = 0; idx < val1.length; idx++) {
|
||||
if (listItemEquals(spec, val1[idx], val2[idx])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function listObjEquals(
|
||||
uniqueBy: UniqueBy,
|
||||
spec: ListValueSpecObject,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
if (uniqueBy === null) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
return itemEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (listObjEquals(subSpec, spec, val1, val2)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!listObjEquals(subSpec, spec, val1, val2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function objEquals(
|
||||
uniqueBy: UniqueBy,
|
||||
spec: ValueSpecObject,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
if (uniqueBy === null) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
// TODO: fix types
|
||||
return itemEquals((spec as any)[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (objEquals(subSpec, spec, val1, val2)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!objEquals(subSpec, spec, val1, val2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function unionEquals(
|
||||
uniqueBy: UniqueBy,
|
||||
spec: ValueSpecUnion | ListValueSpecUnion,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
const tagId = spec.tag.id
|
||||
const variant = spec.variants[val1[tagId]]
|
||||
if (uniqueBy === null) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
if (uniqueBy === tagId) {
|
||||
return val1[tagId] === val2[tagId]
|
||||
} else {
|
||||
return itemEquals(variant[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
}
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (unionEquals(subSpec, spec, val1, val2)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!unionEquals(subSpec, spec, val1, val2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function uniqueByMessageWrapper(
|
||||
uniqueBy: UniqueBy,
|
||||
spec: ListValueSpecObject | ListValueSpecUnion,
|
||||
obj: Record<string, string>,
|
||||
) {
|
||||
let configSpec: ConfigSpec
|
||||
if (isUnion(spec)) {
|
||||
const tagId = spec.tag.id
|
||||
configSpec = {
|
||||
[tagId]: { name: spec.tag.name } as ValueSpec,
|
||||
...spec.variants[obj[tagId]],
|
||||
}
|
||||
} else {
|
||||
configSpec = spec.spec
|
||||
}
|
||||
|
||||
const message = uniqueByMessage(uniqueBy, configSpec)
|
||||
if (message) {
|
||||
return ' Must be unique by: ' + message
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueByMessage(
|
||||
uniqueBy: UniqueBy,
|
||||
configSpec: ConfigSpec,
|
||||
outermost = true,
|
||||
): string {
|
||||
let joinFunc
|
||||
const subSpecs: string[] = []
|
||||
if (uniqueBy === null) {
|
||||
return ''
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
return configSpec[uniqueBy]
|
||||
? (configSpec[uniqueBy] as ValueSpecObject).name
|
||||
: uniqueBy
|
||||
} else if ('any' in uniqueBy) {
|
||||
joinFunc = ' OR '
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
|
||||
}
|
||||
} else if ('all' in uniqueBy) {
|
||||
joinFunc = ' AND '
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
|
||||
}
|
||||
}
|
||||
const ret = subSpecs.filter(Boolean).join(joinFunc)
|
||||
return outermost || subSpecs.filter(ss => ss).length === 1
|
||||
? ret
|
||||
: '(' + ret + ')'
|
||||
}
|
||||
|
||||
function isObjectOrUnion(
|
||||
spec: ListValueSpecOf<any>,
|
||||
): spec is ListValueSpecObject | ListValueSpecUnion {
|
||||
// only lists of objects and unions have unique-by
|
||||
return 'unique-by' in spec
|
||||
}
|
||||
|
||||
function isUnion(spec: any): spec is ListValueSpecUnion {
|
||||
// only unions have tag
|
||||
return !!spec.tag
|
||||
}
|
||||
|
||||
export function convertValuesRecursive(
|
||||
configSpec: ConfigSpec,
|
||||
group: UntypedFormGroup,
|
||||
) {
|
||||
Object.entries(configSpec).forEach(([key, valueSpec]) => {
|
||||
const control = group.get(key)
|
||||
|
||||
if (!control) return
|
||||
|
||||
if (valueSpec.type === 'number') {
|
||||
control.setValue(
|
||||
control.value || control.value === 0 ? Number(control.value) : null,
|
||||
)
|
||||
} else if (valueSpec.type === 'string') {
|
||||
if (!control.value) control.setValue(null)
|
||||
} else if (valueSpec.type === 'object') {
|
||||
convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup)
|
||||
} else if (valueSpec.type === 'union') {
|
||||
const formGr = group.get(key) as UntypedFormGroup
|
||||
const spec = valueSpec.variants[formGr.controls[valueSpec.tag.id].value]
|
||||
convertValuesRecursive(spec, formGr)
|
||||
} else if (valueSpec.type === 'list') {
|
||||
const formArr = group.get(key) as UntypedFormArray
|
||||
const { controls } = formArr
|
||||
|
||||
if (valueSpec.subtype === 'number') {
|
||||
controls.forEach(control => {
|
||||
control.setValue(control.value ? Number(control.value) : null)
|
||||
})
|
||||
} else if (valueSpec.subtype === 'string') {
|
||||
controls.forEach(control => {
|
||||
if (!control.value) control.setValue(null)
|
||||
})
|
||||
} else if (valueSpec.subtype === 'object') {
|
||||
controls.forEach(formGroup => {
|
||||
const objectSpec = valueSpec.spec as ListValueSpecObject
|
||||
convertValuesRecursive(objectSpec.spec, formGroup as UntypedFormGroup)
|
||||
})
|
||||
} else if (valueSpec.subtype === 'union') {
|
||||
controls.forEach(formGroup => {
|
||||
const unionSpec = valueSpec.spec as ListValueSpecUnion
|
||||
const spec =
|
||||
unionSpec.variants[
|
||||
(formGroup as UntypedFormGroup).controls[unionSpec.tag.id].value
|
||||
]
|
||||
convertValuesRecursive(spec, formGroup as UntypedFormGroup)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
321
web/projects/ui/src/app/services/marketplace.service.ts
Normal file
321
web/projects/ui/src/app/services/marketplace.service.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
MarketplacePkg,
|
||||
AbstractMarketplaceService,
|
||||
StoreData,
|
||||
Marketplace,
|
||||
StoreInfo,
|
||||
StoreIdentity,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilKeyChanged,
|
||||
from,
|
||||
mergeMap,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
} from 'rxjs'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
catchError,
|
||||
filter,
|
||||
map,
|
||||
pairwise,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { ConfigService } from './config.service'
|
||||
import { sameUrl } from '@start9labs/shared'
|
||||
import { ClientStorageService } from './client-storage.service'
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceService implements AbstractMarketplaceService {
|
||||
private readonly knownHosts$: Observable<StoreIdentity[]> = this.patch
|
||||
.watch$('ui', 'marketplace', 'known-hosts')
|
||||
.pipe(
|
||||
map(hosts => {
|
||||
const { start9, community } = this.config.marketplace
|
||||
let arr = [
|
||||
toStoreIdentity(start9, hosts[start9]),
|
||||
toStoreIdentity(community, hosts[community]),
|
||||
]
|
||||
|
||||
return arr.concat(
|
||||
Object.entries(hosts)
|
||||
.filter(([url, _]) => ![start9, community].includes(url as any))
|
||||
.map(([url, store]) => toStoreIdentity(url, store)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
private readonly filteredKnownHosts$: Observable<StoreIdentity[]> =
|
||||
combineLatest([
|
||||
this.clientStorageService.showDevTools$,
|
||||
this.knownHosts$,
|
||||
]).pipe(
|
||||
map(([devMode, knownHosts]) =>
|
||||
devMode
|
||||
? knownHosts
|
||||
: knownHosts.filter(
|
||||
({ url }) => !url.includes('alpha') && !url.includes('beta'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private readonly selectedHost$: Observable<StoreIdentity> = this.patch
|
||||
.watch$('ui', 'marketplace')
|
||||
.pipe(
|
||||
distinctUntilKeyChanged('selected-url'),
|
||||
map(({ 'selected-url': url, 'known-hosts': hosts }) =>
|
||||
toStoreIdentity(url, hosts[url]),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
|
||||
private readonly marketplace$ = this.knownHosts$.pipe(
|
||||
startWith<StoreIdentity[]>([]),
|
||||
pairwise(),
|
||||
mergeMap(([prev, curr]) =>
|
||||
curr.filter(c => !prev.find(p => sameUrl(c.url, p.url))),
|
||||
),
|
||||
mergeMap(({ url, name }) =>
|
||||
this.fetchStore$(url).pipe(
|
||||
tap(data => {
|
||||
if (data?.info) this.updateStoreName(url, name, data.info.name)
|
||||
}),
|
||||
map<StoreData | null, [string, StoreData | null]>(data => {
|
||||
return [url, data]
|
||||
}),
|
||||
startWith<[string, StoreData | null]>([url, null]),
|
||||
),
|
||||
),
|
||||
scan<[string, StoreData | null], Record<string, StoreData | null>>(
|
||||
(requests, [url, store]) => {
|
||||
requests[url] = store
|
||||
|
||||
return requests
|
||||
},
|
||||
{},
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
|
||||
private readonly filteredMarketplace$ = combineLatest([
|
||||
this.clientStorageService.showDevTools$,
|
||||
this.marketplace$,
|
||||
]).pipe(
|
||||
map(([devMode, marketplace]) =>
|
||||
Object.entries(marketplace).reduce(
|
||||
(filtered, [url, store]) =>
|
||||
!devMode && (url.includes('alpha') || url.includes('beta'))
|
||||
? filtered
|
||||
: {
|
||||
[url]: store,
|
||||
...filtered,
|
||||
},
|
||||
{} as Marketplace,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private readonly selectedStore$: Observable<StoreData> =
|
||||
this.selectedHost$.pipe(
|
||||
switchMap(({ url }) =>
|
||||
this.marketplace$.pipe(
|
||||
map(m => m[url]),
|
||||
filter(Boolean),
|
||||
take(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private readonly requestErrors$ = new BehaviorSubject<string[]>([])
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly clientStorageService: ClientStorageService,
|
||||
) {}
|
||||
|
||||
getKnownHosts$(filtered = false): Observable<StoreIdentity[]> {
|
||||
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
|
||||
return filtered ? this.filteredKnownHosts$ : this.knownHosts$
|
||||
}
|
||||
|
||||
getSelectedHost$(): Observable<StoreIdentity> {
|
||||
return this.selectedHost$
|
||||
}
|
||||
|
||||
getMarketplace$(filtered = false): Observable<Marketplace> {
|
||||
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
|
||||
return filtered ? this.filteredMarketplace$ : this.marketplace$
|
||||
}
|
||||
|
||||
getSelectedStore$(): Observable<StoreData> {
|
||||
return this.selectedStore$
|
||||
}
|
||||
|
||||
getPackage$(
|
||||
id: string,
|
||||
version: string,
|
||||
optionalUrl?: string,
|
||||
): Observable<MarketplacePkg> {
|
||||
return this.patch.watch$('ui', 'marketplace').pipe(
|
||||
switchMap(uiMarketplace => {
|
||||
const url = optionalUrl || uiMarketplace['selected-url']
|
||||
|
||||
if (version !== '*' || !uiMarketplace['known-hosts'][url]) {
|
||||
return this.fetchPackage$(id, version, url)
|
||||
}
|
||||
|
||||
return this.marketplace$.pipe(
|
||||
map(m => m[url]),
|
||||
filter(Boolean),
|
||||
take(1),
|
||||
map(
|
||||
store =>
|
||||
store.packages.find(p => p.manifest.id === id) ||
|
||||
({} as MarketplacePkg),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// UI only
|
||||
readonly updateErrors: Record<string, string> = {}
|
||||
readonly updateQueue: Record<string, boolean> = {}
|
||||
|
||||
getRequestErrors$(): Observable<string[]> {
|
||||
return this.requestErrors$
|
||||
}
|
||||
|
||||
async installPackage(
|
||||
id: string,
|
||||
version: string,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const params: RR.InstallPackageReq = {
|
||||
id,
|
||||
'version-spec': `=${version}`,
|
||||
'marketplace-url': url,
|
||||
}
|
||||
|
||||
await this.api.installPackage(params)
|
||||
}
|
||||
|
||||
fetchInfo$(url: string): Observable<StoreInfo> {
|
||||
return this.patch.watch$('server-info').pipe(
|
||||
take(1),
|
||||
switchMap(serverInfo => {
|
||||
const qp: RR.GetMarketplaceInfoReq = { 'server-id': serverInfo.id }
|
||||
return this.api.marketplaceProxy<RR.GetMarketplaceInfoRes>(
|
||||
'/package/v0/info',
|
||||
qp,
|
||||
url,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fetchReleaseNotes$(
|
||||
id: string,
|
||||
url?: string,
|
||||
): Observable<Record<string, string>> {
|
||||
return this.selectedHost$.pipe(
|
||||
switchMap(m => {
|
||||
return from(
|
||||
this.api.marketplaceProxy<Record<string, string>>(
|
||||
`/package/v0/release-notes/${id}`,
|
||||
{},
|
||||
url || m.url,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fetchStatic$(id: string, type: string, url?: string): Observable<string> {
|
||||
return this.selectedHost$.pipe(
|
||||
switchMap(m => {
|
||||
return from(
|
||||
this.api.marketplaceProxy<string>(
|
||||
`/package/v0/${type}/${id}`,
|
||||
{},
|
||||
url || m.url,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private fetchStore$(url: string): Observable<StoreData | null> {
|
||||
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
|
||||
map(([info, packages]) => ({ info, packages })),
|
||||
catchError(e => {
|
||||
console.error(e)
|
||||
this.requestErrors$.next(this.requestErrors$.value.concat(url))
|
||||
return of(null)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private fetchPackages$(
|
||||
url: string,
|
||||
params: Omit<RR.GetMarketplacePackagesReq, 'page' | 'per-page'> = {},
|
||||
): Observable<MarketplacePkg[]> {
|
||||
const qp: RR.GetMarketplacePackagesReq = {
|
||||
...params,
|
||||
page: 1,
|
||||
'per-page': 100,
|
||||
}
|
||||
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
|
||||
|
||||
return from(
|
||||
this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
|
||||
'/package/v0/index',
|
||||
qp,
|
||||
url,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fetchPackage$(
|
||||
id: string,
|
||||
version: string,
|
||||
url: string,
|
||||
): Observable<MarketplacePkg> {
|
||||
return this.fetchPackages$(url, { ids: [{ id, version }] }).pipe(
|
||||
map(pkgs => pkgs[0] || {}),
|
||||
)
|
||||
}
|
||||
|
||||
private async updateStoreName(
|
||||
url: string,
|
||||
oldName: string | undefined,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
if (oldName !== newName) {
|
||||
this.api.setDbValue<string>(
|
||||
['marketplace', 'known-hosts', url, 'name'],
|
||||
newName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toStoreIdentity(url: string, uiStore: UIStore): StoreIdentity {
|
||||
return {
|
||||
url,
|
||||
...uiStore,
|
||||
}
|
||||
}
|
||||
24
web/projects/ui/src/app/services/modal.service.ts
Normal file
24
web/projects/ui/src/app/services/modal.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ModalService {
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async presentModalConfig(componentProps: ComponentProps): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: AppConfigPage,
|
||||
componentProps,
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
|
||||
interface ComponentProps {
|
||||
pkgId: string
|
||||
dependentInfo?: DependentInfo
|
||||
}
|
||||
69
web/projects/ui/src/app/services/patch-data.service.ts
Normal file
69
web/projects/ui/src/app/services/patch-data.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { Observable } from 'rxjs'
|
||||
import { filter, share, switchMap, take, tap } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
|
||||
// Get data from PatchDb after is starts and act upon it
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchDataService extends Observable<DataModel> {
|
||||
private readonly stream$ = this.connectionService.connected$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$()),
|
||||
take(1),
|
||||
tap(({ ui }) => {
|
||||
// check for updates to eOS and services
|
||||
this.checkForUpdates()
|
||||
// show eos welcome message
|
||||
this.showEosWelcome(ui['ack-welcome'])
|
||||
}),
|
||||
share(),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly eosService: EOSService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
) {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
|
||||
private checkForUpdates(): void {
|
||||
this.eosService.loadEos()
|
||||
this.marketplaceService.getMarketplace$().pipe(take(1)).subscribe()
|
||||
}
|
||||
|
||||
private async showEosWelcome(ackVersion: string): Promise<void> {
|
||||
if (this.config.skipStartupAlerts || ackVersion === this.config.version) {
|
||||
return
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: OSWelcomePage,
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
backdropDismiss: false,
|
||||
})
|
||||
modal.onWillDismiss().then(() => {
|
||||
this.embassyApi
|
||||
.setDbValue<string>(['ack-welcome'], this.config.version)
|
||||
.catch()
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
376
web/projects/ui/src/app/services/patch-db/data-model.ts
Normal file
376
web/projects/ui/src/app/services/patch-db/data-model.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { Url } from '@start9labs/shared'
|
||||
import { MarketplaceManifest } from '@start9labs/marketplace'
|
||||
import { BasicInfo } from 'src/app/pages/developer-routes/developer-menu/form-info'
|
||||
|
||||
export interface DataModel {
|
||||
'server-info': ServerInfo
|
||||
'package-data': { [id: string]: PackageDataEntry }
|
||||
ui: UIData
|
||||
}
|
||||
|
||||
export interface UIData {
|
||||
name: string | null
|
||||
'ack-welcome': string // eOS emver
|
||||
marketplace: UIMarketplaceData
|
||||
dev: DevData
|
||||
gaming: {
|
||||
snake: {
|
||||
'high-score': number
|
||||
}
|
||||
}
|
||||
'ack-instructions': Record<string, boolean>
|
||||
theme: string
|
||||
widgets: readonly Widget[]
|
||||
}
|
||||
|
||||
export interface Widget {
|
||||
id: string
|
||||
meta: {
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
mobileWidth: number
|
||||
mobileHeight: number
|
||||
}
|
||||
url?: string
|
||||
settings?: string
|
||||
}
|
||||
|
||||
export interface UIMarketplaceData {
|
||||
'selected-url': string
|
||||
'known-hosts': {
|
||||
'https://registry.start9.com/': UIStore
|
||||
'https://community-registry.start9.com/': UIStore
|
||||
[url: string]: UIStore
|
||||
}
|
||||
}
|
||||
|
||||
export interface UIStore {
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface DevData {
|
||||
[id: string]: DevProjectData
|
||||
}
|
||||
|
||||
export interface DevProjectData {
|
||||
name: string
|
||||
instructions: string
|
||||
config: string
|
||||
'basic-info'?: BasicInfo
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string
|
||||
version: string
|
||||
'last-backup': string | null
|
||||
'lan-address': Url
|
||||
'tor-address': Url
|
||||
'ip-info': IpInfo
|
||||
'last-wifi-region': string | null
|
||||
'unread-notification-count': number
|
||||
'status-info': ServerStatusInfo
|
||||
'eos-version-compat': string
|
||||
'password-hash': string
|
||||
hostname: string
|
||||
pubkey: string
|
||||
'ca-fingerprint': string
|
||||
'ntp-synced': boolean
|
||||
zram: boolean
|
||||
platform: string
|
||||
}
|
||||
|
||||
export interface IpInfo {
|
||||
[iface: string]: {
|
||||
ipv4: string | null
|
||||
ipv6: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerStatusInfo {
|
||||
'backup-progress': null | {
|
||||
[packageId: string]: {
|
||||
complete: boolean
|
||||
}
|
||||
}
|
||||
updated: boolean
|
||||
'update-progress': { size: number | null; downloaded: number } | null
|
||||
restarting: boolean
|
||||
'shutting-down': boolean
|
||||
}
|
||||
|
||||
export enum ServerStatus {
|
||||
Running = 'running',
|
||||
Updated = 'updated',
|
||||
BackingUp = 'backing-up',
|
||||
}
|
||||
|
||||
export interface PackageDataEntry {
|
||||
state: PackageState
|
||||
'static-files': {
|
||||
license: Url
|
||||
instructions: Url
|
||||
icon: Url
|
||||
}
|
||||
manifest: Manifest
|
||||
installed?: InstalledPackageDataEntry // exists when: installed, updating
|
||||
'install-progress'?: InstallProgress // exists when: installing, updating
|
||||
}
|
||||
|
||||
export enum PackageState {
|
||||
Installing = 'installing',
|
||||
Installed = 'installed',
|
||||
Updating = 'updating',
|
||||
Removing = 'removing',
|
||||
Restoring = 'restoring',
|
||||
}
|
||||
|
||||
export interface InstalledPackageDataEntry {
|
||||
status: Status
|
||||
manifest: Manifest
|
||||
'last-backup': string | null
|
||||
'system-pointers': any[]
|
||||
'current-dependents': { [id: string]: CurrentDependencyInfo }
|
||||
'current-dependencies': { [id: string]: CurrentDependencyInfo }
|
||||
'dependency-info': {
|
||||
[id: string]: {
|
||||
title: string
|
||||
icon: Url
|
||||
}
|
||||
}
|
||||
'interface-addresses': {
|
||||
[id: string]: { 'tor-address': string; 'lan-address': string }
|
||||
}
|
||||
'marketplace-url': string | null
|
||||
'developer-key': string
|
||||
}
|
||||
|
||||
export interface CurrentDependencyInfo {
|
||||
pointers: any[]
|
||||
'health-checks': string[] // array of health check IDs
|
||||
}
|
||||
|
||||
export interface Manifest extends MarketplaceManifest<DependencyConfig | null> {
|
||||
assets: {
|
||||
license: string // filename
|
||||
instructions: string // filename
|
||||
icon: string // filename
|
||||
docker_images: string // filename
|
||||
assets: string // path to assets folder
|
||||
scripts: string // path to scripts folder
|
||||
}
|
||||
main: ActionImpl
|
||||
'health-checks': Record<
|
||||
string,
|
||||
ActionImpl & { name: string; 'success-message': string | null }
|
||||
>
|
||||
config: ConfigActions | null
|
||||
volumes: Record<string, Volume>
|
||||
'min-os-version': string
|
||||
interfaces: Record<string, InterfaceDef>
|
||||
backup: BackupActions
|
||||
migrations: Migrations | null
|
||||
actions: Record<string, Action>
|
||||
}
|
||||
|
||||
export interface DependencyConfig {
|
||||
check: ActionImpl
|
||||
'auto-configure': ActionImpl
|
||||
}
|
||||
|
||||
export interface ActionImpl {
|
||||
type: 'docker'
|
||||
image: string
|
||||
system: boolean
|
||||
entrypoint: string
|
||||
args: string[]
|
||||
mounts: { [id: string]: string }
|
||||
'io-format': DockerIoFormat | null
|
||||
inject: boolean
|
||||
'shm-size': string
|
||||
'sigterm-timeout': string | null
|
||||
}
|
||||
|
||||
export enum DockerIoFormat {
|
||||
Json = 'json',
|
||||
Yaml = 'yaml',
|
||||
Cbor = 'cbor',
|
||||
Toml = 'toml',
|
||||
}
|
||||
|
||||
export interface ConfigActions {
|
||||
get: ActionImpl | null
|
||||
set: ActionImpl | null
|
||||
}
|
||||
|
||||
export type Volume = VolumeData
|
||||
|
||||
export interface VolumeData {
|
||||
type: VolumeType.Data
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export interface VolumeAssets {
|
||||
type: VolumeType.Assets
|
||||
}
|
||||
|
||||
export interface VolumePointer {
|
||||
type: VolumeType.Pointer
|
||||
'package-id': string
|
||||
'volume-id': string
|
||||
path: string
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export interface VolumeCertificate {
|
||||
type: VolumeType.Certificate
|
||||
'interface-id': string
|
||||
}
|
||||
|
||||
export interface VolumeBackup {
|
||||
type: VolumeType.Backup
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export enum VolumeType {
|
||||
Data = 'data',
|
||||
Assets = 'assets',
|
||||
Pointer = 'pointer',
|
||||
Certificate = 'certificate',
|
||||
Backup = 'backup',
|
||||
}
|
||||
|
||||
export interface InterfaceDef {
|
||||
name: string
|
||||
description: string
|
||||
'tor-config': TorConfig | null
|
||||
'lan-config': LanConfig | null
|
||||
ui: boolean
|
||||
protocols: string[]
|
||||
}
|
||||
|
||||
export interface TorConfig {
|
||||
'port-mapping': { [port: number]: number }
|
||||
}
|
||||
|
||||
export type LanConfig = {
|
||||
[port: number]: { ssl: boolean; mapping: number }
|
||||
}
|
||||
|
||||
export interface BackupActions {
|
||||
create: ActionImpl
|
||||
restore: ActionImpl
|
||||
}
|
||||
|
||||
export interface Migrations {
|
||||
from: { [versionRange: string]: ActionImpl }
|
||||
to: { [versionRange: string]: ActionImpl }
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
name: string
|
||||
description: string
|
||||
warning: string | null
|
||||
implementation: ActionImpl
|
||||
'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[]
|
||||
'input-spec': ConfigSpec | null
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
configured: boolean
|
||||
main: MainStatus
|
||||
'dependency-config-errors': { [id: string]: string | null }
|
||||
}
|
||||
|
||||
export type MainStatus =
|
||||
| MainStatusStopped
|
||||
| MainStatusStopping
|
||||
| MainStatusStarting
|
||||
| MainStatusRunning
|
||||
| MainStatusBackingUp
|
||||
| MainStatusRestarting
|
||||
|
||||
export interface MainStatusStopped {
|
||||
status: PackageMainStatus.Stopped
|
||||
}
|
||||
|
||||
export interface MainStatusStopping {
|
||||
status: PackageMainStatus.Stopping
|
||||
}
|
||||
|
||||
export interface MainStatusStarting {
|
||||
status: PackageMainStatus.Starting
|
||||
restarting: boolean
|
||||
}
|
||||
|
||||
export interface MainStatusRunning {
|
||||
status: PackageMainStatus.Running
|
||||
started: string // UTC date string
|
||||
health: { [id: string]: HealthCheckResult }
|
||||
}
|
||||
|
||||
export interface MainStatusBackingUp {
|
||||
status: PackageMainStatus.BackingUp
|
||||
started: string | null // UTC date string
|
||||
}
|
||||
|
||||
export interface MainStatusRestarting {
|
||||
status: PackageMainStatus.Restarting
|
||||
}
|
||||
|
||||
export enum PackageMainStatus {
|
||||
Starting = 'starting',
|
||||
Running = 'running',
|
||||
Stopping = 'stopping',
|
||||
Stopped = 'stopped',
|
||||
BackingUp = 'backing-up',
|
||||
Restarting = 'restarting',
|
||||
}
|
||||
|
||||
export type HealthCheckResult =
|
||||
| HealthCheckResultStarting
|
||||
| HealthCheckResultLoading
|
||||
| HealthCheckResultDisabled
|
||||
| HealthCheckResultSuccess
|
||||
| HealthCheckResultFailure
|
||||
|
||||
export enum HealthResult {
|
||||
Starting = 'starting',
|
||||
Loading = 'loading',
|
||||
Disabled = 'disabled',
|
||||
Success = 'success',
|
||||
Failure = 'failure',
|
||||
}
|
||||
|
||||
export interface HealthCheckResultStarting {
|
||||
result: HealthResult.Starting
|
||||
}
|
||||
|
||||
export interface HealthCheckResultDisabled {
|
||||
result: HealthResult.Disabled
|
||||
}
|
||||
|
||||
export interface HealthCheckResultSuccess {
|
||||
result: HealthResult.Success
|
||||
}
|
||||
|
||||
export interface HealthCheckResultLoading {
|
||||
result: HealthResult.Loading
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface HealthCheckResultFailure {
|
||||
result: HealthResult.Failure
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
readonly size: number | null
|
||||
readonly downloaded: number
|
||||
readonly 'download-complete': boolean
|
||||
readonly validated: number
|
||||
readonly 'validation-complete': boolean
|
||||
readonly unpacked: number
|
||||
readonly 'unpack-complete': boolean
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { InjectionToken, Injector } from '@angular/core'
|
||||
import {
|
||||
bufferTime,
|
||||
catchError,
|
||||
filter,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { Update } from 'patch-db-client'
|
||||
import { DataModel } from './data-model'
|
||||
import { defer, EMPTY, from, interval, Observable } from 'rxjs'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { ConnectionService } from '../connection.service'
|
||||
import { ApiService } from '../api/embassy-api.service'
|
||||
import { ConfigService } from '../config.service'
|
||||
|
||||
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
|
||||
'',
|
||||
)
|
||||
|
||||
export function sourceFactory(
|
||||
injector: Injector,
|
||||
): Observable<Update<DataModel>[]> {
|
||||
// defer() needed to avoid circular dependency with ApiService, since PatchDB is needed there
|
||||
return defer(() => {
|
||||
const api = injector.get(ApiService)
|
||||
const authService = injector.get(AuthService)
|
||||
const connectionService = injector.get(ConnectionService)
|
||||
const configService = injector.get(ConfigService)
|
||||
const isTor = configService.isTor()
|
||||
const timeout = isTor ? 16000 : 4000
|
||||
|
||||
const websocket$ = api.openPatchWebsocket$().pipe(
|
||||
bufferTime(250),
|
||||
filter(updates => !!updates.length),
|
||||
catchError((_, watch$) => {
|
||||
connectionService.websocketConnected$.next(false)
|
||||
|
||||
return interval(timeout).pipe(
|
||||
switchMap(() =>
|
||||
from(api.echo({ message: 'ping', timeout })).pipe(
|
||||
catchError(() => EMPTY),
|
||||
),
|
||||
),
|
||||
take(1),
|
||||
switchMap(() => watch$),
|
||||
)
|
||||
}),
|
||||
tap(() => connectionService.websocketConnected$.next(true)),
|
||||
)
|
||||
|
||||
return authService.isVerified$.pipe(
|
||||
switchMap(verified => (verified ? websocket$ : EMPTY)),
|
||||
)
|
||||
})
|
||||
}
|
||||
20
web/projects/ui/src/app/services/patch-db/patch-db.module.ts
Normal file
20
web/projects/ui/src/app/services/patch-db/patch-db.module.ts
Normal 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 {}
|
||||
28
web/projects/ui/src/app/services/patch-monitor.service.ts
Normal file
28
web/projects/ui/src/app/services/patch-monitor.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
|
||||
|
||||
// Start and stop PatchDb upon verification
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchMonitorService extends Observable<any> {
|
||||
// @TODO not happy with Observable<void>
|
||||
private readonly stream$ = this.authService.isVerified$.pipe(
|
||||
tap(verified =>
|
||||
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly bootstrapper: LocalStorageBootstrap,
|
||||
) {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
188
web/projects/ui/src/app/services/pkg-status-rendering.service.ts
Normal file
188
web/projects/ui/src/app/services/pkg-status-rendering.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { isEmptyObject } from '@start9labs/shared'
|
||||
import {
|
||||
MainStatusStarting,
|
||||
PackageDataEntry,
|
||||
PackageMainStatus,
|
||||
PackageState,
|
||||
Status,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { PkgDependencyErrors } from './dep-error.service'
|
||||
|
||||
export interface PackageStatus {
|
||||
primary: PrimaryStatus
|
||||
dependency: DependencyStatus | null
|
||||
health: HealthStatus | null
|
||||
}
|
||||
|
||||
export function renderPkgStatus(
|
||||
pkg: PackageDataEntry,
|
||||
depErrors: PkgDependencyErrors,
|
||||
): PackageStatus {
|
||||
let primary: PrimaryStatus
|
||||
let dependency: DependencyStatus | null = null
|
||||
let health: HealthStatus | null = null
|
||||
|
||||
if (pkg.state === PackageState.Installed && pkg.installed) {
|
||||
primary = getPrimaryStatus(pkg.installed.status)
|
||||
dependency = getDependencyStatus(depErrors)
|
||||
health = getHealthStatus(
|
||||
pkg.installed.status,
|
||||
!isEmptyObject(pkg.manifest['health-checks']),
|
||||
)
|
||||
} else {
|
||||
primary = pkg.state as string as PrimaryStatus
|
||||
}
|
||||
|
||||
return { primary, dependency, health }
|
||||
}
|
||||
|
||||
function getPrimaryStatus(status: Status): PrimaryStatus {
|
||||
if (!status.configured) {
|
||||
return PrimaryStatus.NeedsConfig
|
||||
} else if ((status.main as MainStatusStarting).restarting) {
|
||||
return PrimaryStatus.Restarting
|
||||
} else {
|
||||
return status.main.status as any as PrimaryStatus
|
||||
}
|
||||
}
|
||||
|
||||
function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus {
|
||||
return Object.values(depErrors).some(err => !!err)
|
||||
? DependencyStatus.Warning
|
||||
: DependencyStatus.Satisfied
|
||||
}
|
||||
|
||||
function getHealthStatus(
|
||||
status: Status,
|
||||
hasHealthChecks: boolean,
|
||||
): HealthStatus | null {
|
||||
if (status.main.status !== PackageMainStatus.Running || !status.main.health) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = Object.values(status.main.health)
|
||||
|
||||
if (values.some(h => h.result === 'failure')) {
|
||||
return HealthStatus.Failure
|
||||
}
|
||||
|
||||
if (!values.length && hasHealthChecks) {
|
||||
return HealthStatus.Waiting
|
||||
}
|
||||
|
||||
if (values.some(h => h.result === 'loading')) {
|
||||
return HealthStatus.Loading
|
||||
}
|
||||
|
||||
if (values.some(h => !h.result || h.result === 'starting')) {
|
||||
return HealthStatus.Starting
|
||||
}
|
||||
|
||||
return HealthStatus.Healthy
|
||||
}
|
||||
|
||||
export interface StatusRendering {
|
||||
display: string
|
||||
color: string
|
||||
showDots?: boolean
|
||||
}
|
||||
|
||||
export enum PrimaryStatus {
|
||||
// state
|
||||
Installing = 'installing',
|
||||
Updating = 'updating',
|
||||
Removing = 'removing',
|
||||
Restoring = 'restoring',
|
||||
// status
|
||||
Starting = 'starting',
|
||||
Running = 'running',
|
||||
Stopping = 'stopping',
|
||||
Restarting = 'restarting',
|
||||
Stopped = 'stopped',
|
||||
BackingUp = 'backing-up',
|
||||
// config
|
||||
NeedsConfig = 'needs-config',
|
||||
}
|
||||
|
||||
export enum DependencyStatus {
|
||||
Warning = 'warning',
|
||||
Satisfied = 'satisfied',
|
||||
}
|
||||
|
||||
export enum HealthStatus {
|
||||
Failure = 'failure',
|
||||
Waiting = 'waiting',
|
||||
Starting = 'starting',
|
||||
Loading = 'loading',
|
||||
Healthy = 'healthy',
|
||||
}
|
||||
|
||||
export const PrimaryRendering: Record<string, StatusRendering> = {
|
||||
[PrimaryStatus.Installing]: {
|
||||
display: 'Installing',
|
||||
color: 'primary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Updating]: {
|
||||
display: 'Updating',
|
||||
color: 'primary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Removing]: {
|
||||
display: 'Removing',
|
||||
color: 'danger',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Restoring]: {
|
||||
display: 'Restoring',
|
||||
color: 'primary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Stopping]: {
|
||||
display: 'Stopping',
|
||||
color: 'dark-shade',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Restarting]: {
|
||||
display: 'Restarting',
|
||||
color: 'tertiary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Stopped]: {
|
||||
display: 'Stopped',
|
||||
color: 'dark-shade',
|
||||
showDots: false,
|
||||
},
|
||||
[PrimaryStatus.BackingUp]: {
|
||||
display: 'Backing Up',
|
||||
color: 'primary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Starting]: {
|
||||
display: 'Starting',
|
||||
color: 'primary',
|
||||
showDots: true,
|
||||
},
|
||||
[PrimaryStatus.Running]: {
|
||||
display: 'Running',
|
||||
color: 'success',
|
||||
showDots: false,
|
||||
},
|
||||
[PrimaryStatus.NeedsConfig]: {
|
||||
display: 'Needs Config',
|
||||
color: 'warning',
|
||||
showDots: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const DependencyRendering: Record<string, StatusRendering> = {
|
||||
[DependencyStatus.Warning]: { display: 'Issue', color: 'warning' },
|
||||
[DependencyStatus.Satisfied]: { display: 'Satisfied', color: 'success' },
|
||||
}
|
||||
|
||||
export const HealthRendering: Record<string, StatusRendering> = {
|
||||
[HealthStatus.Failure]: { display: 'Failure', color: 'danger' },
|
||||
[HealthStatus.Starting]: { display: 'Starting', color: 'primary' },
|
||||
[HealthStatus.Loading]: { display: 'Loading', color: 'primary' },
|
||||
[HealthStatus.Healthy]: { display: 'Healthy', color: 'success' },
|
||||
}
|
||||
9
web/projects/ui/src/app/services/split-pane.service.ts
Normal file
9
web/projects/ui/src/app/services/split-pane.service.ts
Normal 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)
|
||||
}
|
||||
30
web/projects/ui/src/app/services/storage.service.ts
Normal file
30
web/projects/ui/src/app/services/storage.service.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
37
web/projects/ui/src/app/services/theme-switcher.service.ts
Normal file
37
web/projects/ui/src/app/services/theme-switcher.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService } from './api/embassy-api.service'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ThemeSwitcherService extends BehaviorSubject<string> {
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly embassyApi: ApiService,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {
|
||||
super('Dark')
|
||||
|
||||
this.patch
|
||||
.watch$('ui', 'theme')
|
||||
.pipe(take(1), filter(Boolean))
|
||||
.subscribe(theme => {
|
||||
this.updateTheme(theme)
|
||||
})
|
||||
}
|
||||
|
||||
override next(theme: string): void {
|
||||
this.embassyApi.setDbValue(['theme'], theme)
|
||||
this.updateTheme(theme)
|
||||
}
|
||||
|
||||
private updateTheme(theme: string): void {
|
||||
this.windowRef.document.body.setAttribute('data-theme', theme)
|
||||
super.next(theme)
|
||||
}
|
||||
}
|
||||
59
web/projects/ui/src/app/services/time-service.ts
Normal file
59
web/projects/ui/src/app/services/time-service.ts
Normal 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,
|
||||
) {}
|
||||
}
|
||||
18
web/projects/ui/src/app/services/ui-launcher.service.ts
Normal file
18
web/projects/ui/src/app/services/ui-launcher.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ConfigService } from './config.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UiLauncherService {
|
||||
constructor(
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
launch(pkg: PackageDataEntry): void {
|
||||
this.windowRef.open(this.config.launchableURL(pkg), '_blank', 'noreferrer')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user