feat: redesign service route (#2835)

* feat: redesign service route

* chore: more changes

* remove automated backups and fix interface addresses

* fix rpc methods and slightly better mocks

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2025-02-25 19:33:35 +04:00
committed by GitHub
parent 1b006599cf
commit 7fff9579c0
70 changed files with 2437 additions and 2325 deletions

View File

@@ -83,7 +83,7 @@
<ng-template #trusted>
<div tuiCardLarge tuiSurface="floating" class="card">
<tui-icon icon="@tui.shield" class="g-success" [style.font-size.rem]="4" />
<tui-icon icon="@tui.shield" class="g-positive" [style.font-size.rem]="4" />
<h1>Root CA Trusted!</h1>
<p>
You have successfully trusted your server's Root CA and may now log in

View File

@@ -13,30 +13,29 @@ import { AddressDetails, AddressesService } from './interface.utils'
selector: 'app-address-group',
template: `
<div>
@if (addresses.length) {
@if (addresses.length && !service.static) {
<button
class="icon-add-btn"
tuiIconButton
appearance="secondary"
iconStart="@tui.plus"
(click)="service.add()"
>
Add
</button>
></button>
}
<ng-content />
</div>
@for (address of addresses; track $index) {
<app-address-item [label]="address.label" [address]="address.url" />
} @empty {
<button
tuiButton
iconStart="@tui.plus"
[style.align-self]="'flex-start'"
(click)="service.add()"
>
Add Address
</button>
@if (!service.static) {
<button
tuiButton
iconStart="@tui.plus"
[style.align-self]="'flex-start'"
(click)="service.add()"
>
Add Address
</button>
}
}
`,
imports: [AddressItemComponent, TuiButton],

View File

@@ -7,15 +7,16 @@ import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { getClearnetSpec } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { InterfaceComponent } from '../interface.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { ISB, utils } from '@start9labs/start-sdk'
import { toAcmeName } from 'src/app/utils/acme'
type ClearnetForm = {
domain: string
subdomain: string | null
acme: string
}
@Directive({
@@ -32,18 +33,40 @@ export class ClearnetAddressesDirective implements AddressesService {
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
@Input({ required: true }) network!: NetworkInfo
@Input({ required: true }) acme!: string[]
static = false
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await getClearnetSpec(this.network),
spec: await configBuilderToSpec(
ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.acme.reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
),
buttons: [
{
text: 'Manage domains',
link: 'portal/settings/domains',
},
{
text: 'Save',
handler: async value => this.save(value),
@@ -59,14 +82,23 @@ export class ClearnetAddressesDirective implements AddressesService {
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const { domain, acme } = domainInfo
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.interface.packageContext,
domainInfo,
if (this.interface.packageId) {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId,
host: this.interface.serviceInterface.addressInfo.hostId,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo })
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {

View File

@@ -9,6 +9,7 @@ import { AddressesService } from '../interface.utils'
],
})
export class LocalAddressesDirective implements AddressesService {
static = true
async add() {}
async remove() {}
}

View File

@@ -1,5 +1,17 @@
import { Directive } from '@angular/core'
import { Directive, inject } from '@angular/core'
import { AddressesService } from '../interface.utils'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TuiDialogOptions } from '@taiga-ui/core'
import { FormComponent, FormContext } from '../../form.component'
import { ISB, utils } from '@start9labs/start-sdk'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from '../interface.component'
type OnionForm = {
key: string
}
@Directive({
standalone: true,
@@ -9,6 +21,67 @@ import { AddressesService } from '../interface.utils'
],
})
export class TorAddressesDirective implements AddressesService {
async add() {}
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
static = false
async add() {
const options: Partial<TuiDialogOptions<FormContext<OnionForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
async remove() {}
private async save(form: OnionForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.interface.packageId) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId,
host: this.interface.serviceInterface.addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -12,9 +12,10 @@ import { PatchDB } from 'patch-db-client'
import { AddressGroupComponent } from 'src/app/routes/portal/components/interfaces/address-group.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClearnetAddressesDirective } from './directives/clearnet.directive'
import { LocalAddressesDirective } from './directives/local.directive'
import { TorAddressesDirective } from './directives/tor.directive'
import { LocalAddressesDirective } from './directives/local.directive'
import { AddressDetails } from './interface.utils'
import { map } from 'rxjs'
@Component({
standalone: true,
@@ -22,11 +23,11 @@ import { AddressDetails } from './interface.utils'
template: `
<h3 class="g-title">Clearnet</h3>
<app-address-group
*ngIf="network$ | async as network"
*ngIf="acme$ | async as acme"
clearnetAddresses
tuiCardLarge="compact"
tuiSurface="floating"
[network]="network"
[acme]="acme"
[addresses]="serviceInterface.addresses.clearnet"
>
<em>
@@ -70,9 +71,8 @@ import { AddressDetails } from './interface.utils'
[addresses]="serviceInterface.addresses.local"
>
<em>
Add a local address to expose this interface on your Local Area Network
(LAN). Local addresses can only be accessed by devices connected to the
same LAN as your server, either directly or using a VPN.
Local addresses can only be accessed by devices connected to the same
LAN as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
@@ -95,15 +95,11 @@ import { AddressDetails } from './interface.utils'
],
})
export class InterfaceComponent {
readonly network$ = inject<PatchDB<DataModel>>(PatchDB).watch$(
'serverInfo',
'network',
)
readonly acme$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'acme')
.pipe(map(acme => Object.keys(acme)))
@Input() packageContext?: {
packageId: string
interfaceId: string
}
@Input() packageId?: string
@Input({ required: true }) serviceInterface!: MappedServiceInterface
}

View File

@@ -1,11 +1,10 @@
import { ISB, IST, T, utils } from '@start9labs/start-sdk'
import { T, utils } from '@start9labs/start-sdk'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiConfirmData } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
export abstract class AddressesService {
abstract static: boolean
abstract add(): Promise<void>
abstract remove(): Promise<void>
}
@@ -20,37 +19,7 @@ export const REMOVE: Partial<TuiDialogOptions<TuiConfirmData>> = {
},
}
export function getClearnetSpec({
domains,
start9To,
}: NetworkInfo): Promise<IST.InputSpec> {
const start9ToDomain = `${start9To?.subdomain}.start9.to`
const base = start9To ? { [start9ToDomain]: start9ToDomain } : {}
const values = Object.keys(domains).reduce((prev, curr) => {
return {
[curr]: curr,
...prev,
}
}, base)
return configBuilderToSpec(
ISB.InputSpec.of({
domain: ISB.Value.select({
name: 'Domain',
default: '',
values,
}),
subdomain: ISB.Value.text({
name: 'Subdomain',
required: false,
default: '',
}),
}),
)
}
// @TODO Aiden audit
// @TODO 040 Aiden audit
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
@@ -143,8 +112,6 @@ export function getAddresses(
// )
}
function getLabel(name: string, url: string, multiple: boolean) {}
export type AddressDetails = {
label: string
url: string

View File

@@ -17,17 +17,15 @@ const ROUTES: Routes = [
},
{
path: 'services',
loadChildren: () =>
import('./routes/services/services.module').then(
m => m.ServicesModule,
),
},
{
title: systemTabResolver,
path: 'backups',
loadComponent: () => import('./routes/backups/backups.component'),
data: toNavigationItem('/portal/backups'),
loadChildren: () => import('./routes/services/services.routes'),
},
// @TODO 041
// {
// title: systemTabResolver,
// path: 'backups',
// loadComponent: () => import('./routes/backups/backups.component'),
// data: toNavigationItem('/portal/backups'),
// },
{
title: systemTabResolver,
path: 'logs',

View File

@@ -41,7 +41,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
<td class="available">
<tui-icon
[icon]="target.value.mountable ? '@tui.check' : '@tui.x'"
[class]="target.value.mountable ? 'g-success' : 'g-error'"
[class]="target.value.mountable ? 'g-positive' : 'g-negative'"
/>
</td>
<td class="path">{{ target.value.path }}</td>

View File

@@ -71,9 +71,9 @@ import { HasErrorPipe } from '../pipes/has-error.pipe'
<td class="title">{{ run.job.name || 'No job' }}</td>
<td class="result">
@if (run.report | hasError) {
<tui-icon icon="@tui.x" class="g-error" />
<tui-icon icon="@tui.x" class="g-negative" />
} @else {
<tui-icon icon="@tui.check" class="g-success" />
<tui-icon icon="@tui.check" class="g-positive" />
}
<button tuiLink (click)="showReport(run)">Report</button>
</td>

View File

@@ -6,92 +6,64 @@ import {
Input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { ActionService } from 'src/app/services/action.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getDepDetails } from 'src/app/utils/dep-info'
import { getManifest } from 'src/app/utils/get-package-data'
export type ActionRequest = T.ActionRequest & {
actionName: string
dependency: {
title: string
icon: string
} | null
}
@Component({
standalone: true,
selector: 'button[actionRequest]',
template: `
<tui-icon class="g-warning" [icon]="icon" />
<span tuiTitle>
<strong>{{ actionRequest.actionName }}</strong>
@if (actionRequest.dependency) {
<span tuiSubtitle>
<strong>Service:</strong>
<img
alt=""
[src]="actionRequest.dependency.icon"
[style.width.rem]="1"
/>
{{ actionRequest.dependency.title }}
</span>
}
<strong tuiFade><ng-content /></strong>
<span tuiSubtitle>
{{ actionRequest.reason || 'no reason provided' }}
{{ actionRequest.reason || 'No reason provided' }}
</span>
</span>
`,
styles: `
:host {
width: 100%;
margin: 0 -1rem;
}
strong {
white-space: nowrap;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, TuiTitle],
imports: [TuiTitle, TuiFade],
hostDirectives: [TuiCell],
})
export class ServiceActionRequestComponent {
private readonly actionService = inject(ActionService)
@Input({ required: true })
actionRequest!: ActionRequest
actionRequest!: T.ActionRequest
@Input({ required: true })
pkg!: PackageDataEntry
@Input({ required: true })
allPkgs!: Record<string, PackageDataEntry>
get icon(): string {
return this.actionRequest.severity === 'critical'
? '@tui.triangle-alert'
: '@tui.play'
}
@HostListener('click')
async handleAction() {
const { id, title } = getManifest(this.pkg)
const { title } = getManifest(this.pkg)
const { actionId, packageId } = this.actionRequest
const details = getDepDetails(this.pkg, this.allPkgs, packageId)
const self = packageId === id
this.actionService.present({
pkgInfo: {
id: packageId,
title: self ? title : details.title,
mainStatus: self
? this.pkg.status.main
: this.allPkgs[packageId].status.main,
icon: self ? this.pkg.icon : details.icon,
title,
mainStatus: this.pkg.status.main,
icon: this.pkg.icon,
},
actionInfo: {
id: actionId,
metadata: self
? this.pkg.actions[actionId]
: this.allPkgs[packageId].actions[actionId],
},
requestInfo: {
request: this.actionRequest,
dependentId: self ? undefined : id,
metadata: this.pkg.actions[actionId],
},
requestInfo: this.actionRequest,
})
}
}

View File

@@ -0,0 +1,76 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionRequestComponent } from './action-request.component'
type ActionRequest = T.ActionRequest & {
actionName: string
}
@Component({
standalone: true,
selector: 'service-action-requests',
template: `
@for (request of requests().critical; track $index) {
<button [actionRequest]="request" [pkg]="pkg()">
{{ request.actionName }}
<small class="g-warning">Required</small>
</button>
}
@for (request of requests().important; track $index) {
<button [actionRequest]="request" [pkg]="pkg()">
{{ request.actionName }}
<small class="g-info">Requested</small>
</button>
}
@if (requests().critical.length + requests().important.length === 0) {
<blockquote>No pending tasks</blockquote>
}
`,
styles: `
small {
margin-inline-start: 0.25rem;
padding-inline-start: 0.5rem;
box-shadow: inset 1px 0 var(--tui-border-normal);
}
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceActionRequestComponent],
})
export class ServiceActionRequestsComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly requests = computed(() => {
const { id } = getManifest(this.pkg())
const critical: ActionRequest[] = []
const important: ActionRequest[] = []
Object.values(this.pkg().requestedActions)
.filter(r => r.active && r.request.packageId === id)
.forEach(r => {
const action = {
...r.request,
actionName: this.pkg().actions[r.request.actionId].name,
}
if (r.request.severity === 'critical') {
critical.push(action)
} else {
important.push(action)
}
})
return { critical, important }
})
}

View File

@@ -1,4 +1,3 @@
import { TuiButton } from '@taiga-ui/core'
import {
ChangeDetectionStrategy,
Component,
@@ -7,22 +6,20 @@ import {
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { TuiButton } from '@taiga-ui/core'
import { DependencyInfo } from 'src/app/routes/portal/routes/services/types/dependency-info'
import { ControlsService } from '../../../../../services/controls.service'
import { ControlsService } from 'src/app/services/controls.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PackageStatus } from 'src/app/services/pkg-status-rendering.service'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
const STOPPABLE = ['running', 'starting', 'restarting']
@Component({
selector: 'service-actions',
template: `
@if (canStop) {
@if (['running', 'starting', 'restarting'].includes(status)) {
<button
tuiButton
appearance="primary-destructive"
appearance="outline-destructive"
iconStart="@tui.square"
(click)="actions.stop(manifest)"
>
@@ -30,9 +27,10 @@ const STOPPABLE = ['running', 'starting', 'restarting']
</button>
}
@if (canRestart) {
@if (status === 'running') {
<button
tuiButton
appearance="outline"
iconStart="@tui.rotate-cw"
(click)="actions.restart(manifest)"
>
@@ -40,11 +38,12 @@ const STOPPABLE = ['running', 'starting', 'restarting']
</button>
}
@if (canStart) {
@if (status === 'stopped') {
<button
tuiButton
appearance="outline"
iconStart="@tui.play"
(click)="actions.start(manifest, hasUnmet(service.dependencies))"
(click)="actions.start(manifest, hasUnmet(dependencies))"
>
Start
</button>
@@ -53,42 +52,32 @@ const STOPPABLE = ['running', 'starting', 'restarting']
styles: [
`
:host {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-bottom: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 1rem;
justify-content: center;
margin-block-start: 1rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButton],
providers: [tuiButtonOptionsProvider({ size: 's' })],
})
export class ServiceActionsComponent {
@Input({ required: true })
service!: {
pkg: PackageDataEntry
dependencies: readonly DependencyInfo[]
status: PackageStatus
}
pkg!: PackageDataEntry
@Input({ required: true })
status!: PrimaryStatus
// TODO
dependencies: readonly DependencyInfo[] = []
readonly actions = inject(ControlsService)
get manifest(): T.Manifest {
return getManifest(this.service.pkg)
}
get canStop(): boolean {
return STOPPABLE.includes(this.service.status.primary)
}
get canStart(): boolean {
return this.service.status.primary === 'stopped'
}
get canRestart(): boolean {
return this.service.status.primary === 'running'
return getManifest(this.pkg)
}
@tuiPure

View File

@@ -1,17 +1,21 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon } from '@taiga-ui/core'
import {
AdditionalItem,
FALLBACK_URL,
} from 'src/app/routes/portal/routes/services/pipes/to-additional.pipe'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
export const FALLBACK_URL = 'Not provided'
export interface AdditionalItem {
name: string
description: string
icon?: string
action?: () => void
}
@Component({
selector: '[additionalItem]',
template: `
<div [style.flex]="1">
<span tuiTitle>
<strong>{{ additionalItem.name }}</strong>
<div>{{ additionalItem.description }}</div>
</div>
<span tuiSubtitle>{{ additionalItem.description }}</span>
</span>
@if (icon) {
<tui-icon [icon]="icon" />
}
@@ -33,7 +37,7 @@ import {
},
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiIcon],
imports: [TuiIcon, TuiTitle],
})
export class ServiceAdditionalItemComponent {
@Input({ required: true })

View File

@@ -1,84 +0,0 @@
import { TuiButton } from '@taiga-ui/core'
import { I18nPluralPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
@Component({
selector: 'service-backups',
template: `
<div [style.flex]="1">
<small>Last backup</small>
{{ previous() | i18nPlural: ago }}
</div>
<div [style.flex]="1">
<small>Next backup</small>
{{ next() | i18nPlural: in }}
</div>
<div [style.min-width.%]="100">
<a
tuiButton
iconStart="@tui.square-plus"
routerLink="/portal/backups"
size="s"
appearance="warning"
>
Manage
</a>
</div>
`,
styles: `
:host {
display: flex;
gap: 1rem;
flex-wrap: wrap;
white-space: nowrap;
padding-bottom: 1rem;
small {
display: block;
text-transform: uppercase;
color: var(--tui-text-secondary);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButton, RouterLink, I18nPluralPipe],
})
export class ServiceBackupsComponent {
pkg = input.required<T.PackageDataEntry>()
readonly previous = computed(() =>
daysBetween(new Date(), new Date(this.pkg().lastBackup || new Date())),
)
readonly next = computed(() =>
// TODO @lucy add this back in when types fixed for PackageDataEntry ie. when next/minor merge resolved
// daysBetween(new Date(), new Date(this.pkg().nextBackup || new Date())),
daysBetween(new Date(), new Date(new Date())),
)
readonly ago = {
'=0': 'Never performed',
'=1': 'day ago',
other: '# days ago',
}
readonly in = {
'=0': 'Not scheduled',
'=1': 'Tomorrow',
other: 'In # days',
}
}
function daysBetween(one: Date, two: Date): number {
return Math.abs(
Math.round((one.valueOf() - two.valueOf()) / (1000 * 60 * 60 * 24)),
)
}

View File

@@ -1,22 +1,75 @@
import { KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { DependencyInfo } from '../types/dependency-info'
import { ServiceDependencyComponent } from './dependency.component'
import { RouterLink } from '@angular/router'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServiceActionRequestsComponent } from './action-requests.component'
@Component({
selector: 'service-dependencies',
template: `
@for (dep of dependencies; track $index) {
<button [serviceDependency]="dep"></button>
<header>Dependencies</header>
@for (d of pkg.currentDependencies | keyvalue; track $index) {
<a
tuiCell
[routerLink]="services[d.key] ? ['..', d.key] : ['/portal/marketplace']"
[queryParams]="services[d.key] ? {} : { id: d.key }"
>
<tui-avatar><img alt="" [src]="d.value.icon" /></tui-avatar>
<span tuiTitle>
{{ d.value.title }}
<span tuiSubtitle>{{ d.value.versionRange }}</span>
</span>
<tui-icon icon="@tui.arrow-right" />
</a>
@if (services[d.key]; as service) {
<service-action-requests [pkg]="service" />
}
} @empty {
No dependencies
<blockquote>No dependencies</blockquote>
}
`,
styles: ':host { display: block; min-height: var(--tui-height-s) }',
styles: `
a {
margin: 0 -1rem;
&::after {
display: none;
}
}
service-action-requests {
display: block;
padding: 1rem 0 0 2.375rem;
margin: -1rem 0 1rem 1.125rem;
box-shadow: inset 0.125rem 0 var(--tui-border-normal);
}
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ServiceDependencyComponent],
imports: [
KeyValuePipe,
TuiCell,
TuiAvatar,
TuiTitle,
ServiceActionRequestsComponent,
RouterLink,
TuiIcon,
],
})
export class ServiceDependenciesComponent {
@Input({ required: true })
dependencies: readonly DependencyInfo[] = []
pkg!: PackageDataEntry
@Input({ required: true })
services: Record<string, PackageDataEntry> = {}
}

View File

@@ -1,59 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { DependencyInfo } from '../types/dependency-info'
@Component({
selector: '[serviceDependency]',
template: `
<img [src]="dep.icon" alt="" />
<span tuiTitle>
<strong>
@if (dep.errorText) {
<tui-icon icon="@tui.triangle-alert" [style.color]="color" />
}
{{ dep.title }}
</strong>
<span tuiSubtitle>{{ dep.version }}</span>
<span tuiSubtitle="" [style.color]="color">
{{ dep.errorText || 'Satisfied' }}
</span>
</span>
@if (dep.actionText) {
<span>
{{ dep.actionText }}
<tui-icon icon="@tui.arrow-right" />
</span>
}
`,
styles: [
`
img {
width: 1.5rem;
height: 1.5rem;
border-radius: 100%;
}
tui-icon {
font-size: 1rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: {
'(click)': 'dep.action()',
},
imports: [TuiIcon, TuiTitle],
hostDirectives: [TuiCell],
})
export class ServiceDependencyComponent {
@Input({ required: true, alias: 'serviceDependency' })
dep!: DependencyInfo
get color(): string {
return this.dep.errorText
? 'var(--tui-status-warning)'
: 'var(--tui-status-positive)'
}
}

View File

@@ -20,17 +20,15 @@ import { getManifest } from 'src/app/utils/get-package-data'
standalone: true,
selector: 'service-error',
template: `
<header>Error</header>
<tui-line-clamp
style="pointer-events: none; margin: 1rem 0 -1rem; color: var(--tui-status-negative);"
[linesLimit]="2"
[content]="error?.message"
(overflownChange)="overflow = $event"
/>
<h4 class="g-title">
<span [style.display]="'flex'">
Actions
<tui-icon [style.margin-left.rem]="0.25" [tuiTooltip]="hint" />
</span>
<h4>
Actions
<tui-icon [tuiTooltip]="hint" />
</h4>
<ng-template #hint>
<div>
@@ -54,6 +52,32 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
</p>
`,
styles: `
:host {
grid-column: span 2;
}
header {
--tui-background-neutral-1: var(--tui-status-negative-pale);
}
tui-line-clamp {
pointer-events: none;
margin: 1rem 0;
color: var(--tui-status-negative);
}
h4 {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-secondary);
text-transform: uppercase;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiIcon, TuiTooltip, TuiLineClamp],
})

View File

@@ -10,22 +10,28 @@ import { ServiceHealthCheckComponent } from 'src/app/routes/portal/routes/servic
import { ConnectionService } from 'src/app/services/connection.service'
@Component({
standalone: true,
selector: 'service-health-checks',
template: `
<header>Health Checks</header>
@for (check of checks; track $index) {
<service-health-check
[check]="check"
[connected]="!!(connected$ | async)"
/>
}
@if (!checks.length) {
No health checks
} @empty {
<blockquote>No health checks</blockquote>
}
`,
styles: ':host { display: block; min-height: var(--tui-height-s) }',
styles: `
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [AsyncPipe, ServiceHealthCheckComponent],
})
export class ServiceHealthChecksComponent {

View File

@@ -1,86 +0,0 @@
import { TuiLet } from '@taiga-ui/cdk'
import { TuiLoader, TuiIcon, TuiButton, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiCell } from '@taiga-ui/layout'
import { map, timer } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
@Component({
selector: 'a[serviceInterfaceListItem]',
template: `
<ng-container *tuiLet="healthCheck$ | async as check">
@if (check === null) {
<tui-loader />
} @else if (check === '') {
<tui-icon [icon]="info.icon" [style.color]="info.color" />
} @else {
<tui-icon icon="@tui.circle-x" class="g-error" />
}
<span tuiTitle>
<strong>{{ info.name }}</strong>
<span tuiSubtitle>{{ info.description }}</span>
@if (check) {
<span tuiSubtitle class="g-error">
<span>
<b>Health check failed:</b>
{{ check }}
</span>
</span>
} @else {
<span tuiSubtitle [style.color]="info.color">
{{ info.typeDetail }}
</span>
}
</span>
@if (info.type === 'ui') {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
title="Open"
size="m"
[style.border-radius.%]="100"
[attr.href]="href"
(click.stop)="(0)"
></a>
}
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButton, TuiLet, TuiLoader, TuiIcon, TuiTitle],
hostDirectives: [TuiCell],
})
export class ServiceInterfaceListItemComponent {
private readonly config = inject(ConfigService)
@Input({ required: true })
info!: ExtendedInterfaceInfo
@Input({ required: true })
pkg!: PackageDataEntry
@Input()
disabled = false
// TODO: Implement real health check
readonly healthCheck$ = timer(3000).pipe(
map(() => (Math.random() > 0.5 ? '' : 'You done f***d it up...')),
)
get href(): string | null {
return this.disabled
? 'null'
: this.config.launchableAddress(this.info, this.pkg.hosts)
}
}

View File

@@ -1,31 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PackageStatus } from 'src/app/services/pkg-status-rendering.service'
import { InterfaceInfoPipe } from '../pipes/interface-info.pipe'
import { ServiceInterfaceListItemComponent } from './interface-list-item.component'
@Component({
selector: 'service-interface-list',
template: `
@for (info of pkg | interfaceInfo; track $index) {
<a
serviceInterfaceListItem
[info]="info"
[pkg]="pkg"
[disabled]="status.primary !== 'running'"
[routerLink]="info.routerLink"
></a>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [RouterLink, InterfaceInfoPipe, ServiceInterfaceListItemComponent],
})
export class ServiceInterfaceListComponent {
@Input({ required: true })
pkg!: PackageDataEntry
@Input({ required: true })
status!: PackageStatus
}

View File

@@ -0,0 +1,158 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from '../../../../../utils/get-package-data'
import { MappedInterface } from '../types/mapped-interface'
@Component({
selector: 'tr[serviceInterface]',
template: `
<td>
<a tuiLink [routerLink]="info.routerLink">
<strong>{{ info.name }}</strong>
</a>
</td>
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
</td>
<td class="g-secondary">{{ info.description }}</td>
<td class="hosting">
@if (info.public) {
<button
tuiButton
size="s"
iconStart="@tui.globe"
appearance="positive"
(click)="toggle()"
>
Public
</button>
} @else {
<button
tuiButton
size="s"
iconStart="@tui.lock"
appearance="negative"
(click)="toggle()"
>
Private
</button>
}
</td>
<td>
@if (info.type === 'ui') {
<a
tuiButton
appearance="action"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
size="s"
[style.border-radius.%]="100"
[attr.href]="href"
(click.stop)="(0)"
>
Open
</a>
}
</td>
`,
styles: `
strong {
white-space: nowrap;
}
tui-badge {
text-transform: uppercase;
}
.hosting {
white-space: nowrap;
}
:host-context(tui-root._mobile) {
display: block;
padding: 0.5rem 0;
td {
display: inline-block;
}
.hosting {
font-size: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButton, TuiBadge, TuiLink, RouterLink],
})
export class ServiceInterfaceComponent {
private readonly config = inject(ConfigService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
@Input({ required: true })
info!: MappedInterface
@Input({ required: true })
pkg!: PackageDataEntry
@Input()
disabled = false
get appearance(): string {
switch (this.info.type) {
case 'ui':
return 'primary'
case 'api':
return 'accent'
case 'p2p':
return 'primary-grayscale'
}
}
get href(): string | null {
return this.disabled
? 'null'
: this.config.launchableAddress(this.info, this.pkg.hosts)
}
async toggle() {
const loader = this.loader
.open(`Making ${this.info.public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.info.addressInfo.internalPort,
public: !this.info.public,
}
try {
if (!this.info.public) {
await this.api.pkgBindingSetPubic({
...params,
host: this.info.addressInfo.hostId,
package: getManifest(this.pkg).id,
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { TuiTable } from '@taiga-ui/addon-table'
import { tuiDefaultSort } from '@taiga-ui/cdk'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ServiceInterfaceComponent } from './interface.component'
@Component({
standalone: true,
selector: 'service-interfaces',
template: `
<header>Interfaces</header>
<table tuiTable class="g-table">
<thead>
<tr>
<th tuiTh>Name</th>
<th tuiTh>Type</th>
<th tuiTh>Description</th>
<th tuiTh>Hosting</th>
<th tuiTh></th>
</tr>
</thead>
@for (info of interfaces(); track $index) {
<tr
serviceInterface
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
></tr>
}
</table>
`,
styles: `
:host {
grid-column: span 2;
}
table {
margin: 0 -0.5rem;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceInterfaceComponent, TuiTable],
})
export class ServiceInterfacesComponent {
private readonly config = inject(ConfigService)
readonly pkg = input.required<PackageDataEntry>()
readonly disabled = input(false)
readonly interfaces = computed(({ serviceInterfaces, hosts } = this.pkg()) =>
Object.entries(serviceInterfaces)
.sort((a, b) => tuiDefaultSort(a[1], b[1]))
.map(([id, value]) => {
const host = hosts[value.addressInfo.hostId]
return {
...value,
public: !!host?.bindings[value.addressInfo.internalPort].net.public,
addresses: host ? getAddresses(value, host, this.config) : {},
routerLink: `./interface/${id}`,
}
}),
)
}

View File

@@ -1,27 +0,0 @@
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiCell } from '@taiga-ui/layout'
import { ServiceMenu } from '../pipes/to-menu.pipe'
@Component({
selector: '[serviceMenuItem]',
template: `
<tui-icon [icon]="menu.icon" />
<span tuiTitle [style.flex]="1">
<strong>{{ menu.name }}</strong>
<span tuiSubtitle>
{{ menu.description }}
</span>
<ng-content />
</span>
<tui-icon icon="@tui.chevron-right" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiIcon, TuiTitle],
hostDirectives: [TuiCell],
})
export class ServiceMenuItemComponent {
@Input({ required: true, alias: 'serviceMenuItem' })
menu!: ServiceMenu
}

View File

@@ -1,41 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ToMenuPipe } from '../pipes/to-menu.pipe'
import { ServiceMenuItemComponent } from './menu-item.component'
@Component({
selector: 'service-menu',
template: `
@for (menu of pkg | toMenu; track $index) {
@if (menu.routerLink) {
<a
[serviceMenuItem]="menu"
[routerLink]="menu.routerLink"
[queryParams]="menu.params || {}"
></a>
} @else {
<button [serviceMenuItem]="menu" (click)="menu.action?.()">
@if (menu.name === 'Outbound Proxy') {
<div tuiSubtitle [style.color]="color">
{{ pkg.outboundProxy || 'None' }}
</div>
}
</button>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ToMenuPipe, ServiceMenuItemComponent, RouterLink],
})
export class ServiceMenuComponent {
@Input({ required: true })
pkg!: PackageDataEntry
get color(): string {
return this.pkg.outboundProxy
? 'var(--tui-status-positive)'
: 'var(--tui-status-warning)'
}
}

View File

@@ -7,36 +7,49 @@ import {
} from '@angular/core'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
import { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
import {
PrimaryRendering,
PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@Component({
selector: 'service-status',
template: `
@if (installingInfo) {
<strong>
<tui-loader size="s" [inheritColor]="true" />
Installing
<span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }}
</strong>
} @else {
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" />
{{ connected ? rendering.display : 'Unknown' }}
@if (rendering.showDots) {
<span class="loading-dots"></span>
<header>Status</header>
<div [class]="class">
@if (installingInfo) {
<strong>
<tui-loader size="s" [inheritColor]="true" />
Installing
<span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }}
</strong>
} @else {
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" />
{{ connected ? rendering.display : 'Unknown' }}
@if (rendering.showDots) {
<span class="loading-dots"></span>
}
}
}
<ng-content />
</div>
`,
styles: [
`
:host {
display: block;
font-size: x-large;
white-space: nowrap;
margin: auto 0;
min-height: 2.75rem;
color: var(--tui-text-secondary);
display: grid;
grid-template-rows: min-content 1fr;
align-items: center;
font: var(--tui-font-heading-6);
text-align: center;
}
status {
display: grid;
grid-template-rows: min-content 1fr 1fr;
align-items: center;
}
tui-loader {
@@ -44,21 +57,16 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
vertical-align: bottom;
margin: 0 0.25rem -0.125rem 0;
}
div {
font-size: 1rem;
color: var(--tui-text-secondary);
margin: 1rem 0;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-card' },
standalone: true,
imports: [CommonModule, InstallingProgressDisplayPipe, TuiIcon, TuiLoader],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InstallingProgressDisplayPipe, TuiIcon, TuiLoader],
})
export class ServiceStatusComponent {
@Input({ required: true })
rendering!: StatusRendering
status!: PrimaryStatus
@Input()
installingInfo?: InstallingInfo
@@ -66,17 +74,16 @@ export class ServiceStatusComponent {
@Input()
connected = false
@HostBinding('class')
get class(): string | null {
if (!this.connected) return null
switch (this.rendering.color) {
case 'danger':
return 'g-error'
return 'g-negative'
case 'warning':
return 'g-warning'
case 'success':
return 'g-success'
return 'g-positive'
case 'primary':
return 'g-info'
default:
@@ -84,6 +91,10 @@ export class ServiceStatusComponent {
}
}
get rendering() {
return PrimaryRendering[this.status]
}
get icon(): string {
if (!this.connected) return '@tui.circle'

View File

@@ -12,7 +12,7 @@ import { ServicesService } from './services.service'
@Component({
standalone: true,
template: `
<table tuiTable [(sorter)]="sorter">
<table tuiTable class="g-table" [(sorter)]="sorter">
<thead>
<tr>
<th [style.width.rem]="3"></th>
@@ -53,41 +53,12 @@ import { ServicesService } from './services.service'
font-size: 1rem;
overflow: hidden;
}
table {
width: 100%;
}
tr:not(:last-child) {
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
}
th {
text-transform: uppercase;
color: var(--tui-text-secondary);
background: none;
border: none;
font: var(--tui-font-text-s);
font-weight: bold;
text-align: left;
padding: 0 0.5rem;
}
td {
text-align: center;
padding: 1rem;
}
:host-context(tui-root._mobile) {
thead {
display: none;
}
}
`,
host: { class: 'g-page' },
imports: [ServiceComponent, ToManifestPipe, TuiTable],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
export default class DashboardComponent {
readonly services = toSignal(inject(ServicesService))
readonly errors = toSignal(inject(DepErrorService).depErrors$)

View File

@@ -63,10 +63,6 @@ import { StatusComponent } from './status.component'
border-radius: 100%;
}
td {
padding: 0.5rem;
}
a {
color: var(--tui-text-primary);
font-weight: bold;

View File

@@ -18,7 +18,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
<tui-loader size="s" />
} @else {
@if (healthy) {
<tui-icon icon="@tui.check" class="g-success" />
<tui-icon icon="@tui.check" class="g-positive" />
} @else {
<tui-icon icon="@tui.triangle-alert" class="g-warning" />
}

View File

@@ -37,10 +37,7 @@ export type PackageActionData = {
id: string
metadata: T.ActionMetadata
}
requestInfo?: {
dependentId?: string
request: T.ActionRequest
}
requestInfo?: T.ActionRequest
}
@Component({
@@ -153,12 +150,12 @@ export class ActionInputModal {
return {
spec: res.spec,
originalValue,
operations: this.requestInfo?.request.input
operations: this.requestInfo?.input
? compare(
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.request.input.value,
this.requestInfo.input.value,
) as object,
)
: null,

View File

@@ -1,30 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiDialogOptions } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
import { ToAdditionalPipe } from 'src/app/routes/portal/routes/services/pipes/to-additional.pipe'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServiceAdditionalItemComponent } from './additional-item.component'
@Component({
selector: 'service-additional',
template: `
@for (additional of pkg | toAdditional; track $index) {
@if (additional.description.startsWith('http')) {
<a class="g-action" [additionalItem]="additional"></a>
} @else {
<button
class="g-action"
[style.pointer-events]="!additional.icon ? 'none' : null"
[additionalItem]="additional"
(click)="additional.action?.()"
></button>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ToAdditionalPipe, ServiceAdditionalItemComponent],
})
export class ServiceAdditionalModal {
readonly pkg = injectContext<TuiDialogOptions<PackageDataEntry>>().data
}

View File

@@ -1,6 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core'
import { T } from '@start9labs/start-sdk'
// TODO drop these pipes
@Pipe({
standalone: true,
name: 'installingProgressString',

View File

@@ -1,52 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
export interface ExtendedInterfaceInfo extends T.ServiceInterface {
id: string
icon: string
color: string
typeDetail: string
routerLink: string
}
@Pipe({
name: 'interfaceInfo',
standalone: true,
})
export class InterfaceInfoPipe implements PipeTransform {
transform(pkg: PackageDataEntry): ExtendedInterfaceInfo[] {
return Object.entries(pkg.serviceInterfaces).map(([id, val]) => {
let color: string
let icon: string
let typeDetail: string
switch (val.type) {
case 'ui':
color = 'var(--tui-text-action)'
icon = '@tui.monitor'
typeDetail = 'User Interface (UI)'
break
case 'p2p':
color = 'var(--tui-background-accent-2)'
icon = '@tui.users'
typeDetail = 'Peer-To-Peer Interface (P2P)'
break
case 'api':
color = 'var(--tui-status-info)'
icon = '@tui.terminal'
typeDetail = 'Application Program Interface (API)'
break
}
return {
...val,
id,
color,
icon,
typeDetail,
routerLink: `./interface/${id}`,
}
})
}
}

View File

@@ -1,41 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getDepDetails } from 'src/app/utils/dep-info'
import { getManifest } from 'src/app/utils/get-package-data'
import { ActionRequest } from '../components/action-request.component'
@Pipe({
standalone: true,
name: 'toActionRequests',
})
export class ToActionRequestsPipe implements PipeTransform {
transform(pkg: PackageDataEntry, packages: Record<string, PackageDataEntry>) {
const { id } = getManifest(pkg)
const critical: ActionRequest[] = []
const important: ActionRequest[] = []
Object.values(pkg.requestedActions)
.filter(r => r.active)
.forEach(r => {
const self = r.request.packageId === id
const toReturn = {
...r.request,
actionName: self
? pkg.actions[r.request.actionId].name
: packages[r.request.packageId]?.actions[r.request.actionId].name ||
'Unknown Action',
dependency: self
? null
: getDepDetails(pkg, packages, r.request.packageId),
}
if (r.request.severity === 'critical') {
critical.push(toReturn)
} else {
important.push(toReturn)
}
})
return { critical, important }
}
}

View File

@@ -1,81 +0,0 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { CopyService, MARKDOWN } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { from } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
export const FALLBACK_URL = 'Not provided'
export interface AdditionalItem {
name: string
description: string
icon?: string
action?: () => void
}
@Pipe({
name: 'toAdditional',
standalone: true,
})
export class ToAdditionalPipe implements PipeTransform {
private readonly api = inject(ApiService)
private readonly copyService = inject(CopyService)
private readonly dialogs = inject(TuiDialogService)
transform(pkg: PackageDataEntry): AdditionalItem[] {
const manifest = getManifest(pkg)
return [
{
name: 'Installed',
description: new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date(pkg.installedAt || 0)),
},
{
name: 'Git Hash',
description: manifest.gitHash || 'Unknown',
icon: manifest.gitHash ? '@tui.copy' : '',
action: () =>
manifest.gitHash && this.copyService.copy(manifest.gitHash),
},
{
name: 'License',
description: manifest.license,
icon: '@tui.chevron-right',
action: () => this.showLicense(manifest),
},
{
name: 'Website',
description: manifest.marketingSite || FALLBACK_URL,
},
{
name: 'Source Repository',
description: manifest.upstreamRepo,
},
{
name: 'Support Site',
description: manifest.supportSite || FALLBACK_URL,
},
{
name: 'Donation Link',
description: manifest.donationUrl || FALLBACK_URL,
},
]
}
private showLicense({ id, version }: T.Manifest) {
this.dialogs
.open(MARKDOWN, {
label: 'License',
size: 'l',
data: {
content: from(this.api.getStaticInstalled(id, 'LICENSE.md')),
},
})
.subscribe()
}
}

View File

@@ -1,109 +0,0 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { Params } from '@angular/router'
import { MARKDOWN } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { from } from 'rxjs'
import { ServiceAdditionalModal } from 'src/app/routes/portal/routes/services/modals/additional.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ProxyService } from 'src/app/services/proxy.service'
import { getManifest } from 'src/app/utils/get-package-data'
export interface ServiceMenu {
icon: string
name: string
description: string
action?: () => void
routerLink?: string
params?: Params
}
@Pipe({
name: 'toMenu',
standalone: true,
})
export class ToMenuPipe implements PipeTransform {
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly formDialog = inject(FormDialogService)
private readonly proxyService = inject(ProxyService)
transform(pkg: PackageDataEntry): ServiceMenu[] {
const manifest = getManifest(pkg)
return [
{
icon: '@tui.list',
name: 'Instructions',
description: `Understand how to use ${manifest.title}`,
action: () => this.showInstructions(manifest),
},
{
icon: '@tui.zap',
name: 'Actions',
description: `Uninstall and other commands specific to ${manifest.title}`,
routerLink: `actions`,
},
{
icon: '@tui.shield',
name: 'Outbound Proxy',
description: `Proxy all outbound traffic from ${manifest.title}`,
action: () =>
this.proxyService.presentModalSetOutboundProxy(
pkg.outboundProxy,
manifest.id,
),
},
{
icon: '@tui.file-text',
name: 'Logs',
description: `Raw, unfiltered logs`,
routerLink: 'logs',
},
{
icon: '@tui.info',
name: 'Additional Info',
description: `View package details`,
action: () =>
this.dialogs
.open(new PolymorpheusComponent(ServiceAdditionalModal), {
label: `Additional Info`,
data: pkg,
})
.subscribe(),
},
pkg.registry
? {
icon: '@tui.shopping-bag',
name: 'Marketplace Listing',
description: `View ${manifest.title} on the Marketplace`,
routerLink: `/portal/marketplace`,
params: { url: pkg.registry, id: manifest.id },
}
: {
icon: '@tui.shopping-bag',
name: 'Marketplace Listing',
description: `This package was not installed from the marketplace`,
},
]
}
private showInstructions({ title, id }: T.Manifest) {
this.api
.setDbValue<boolean>(['ack-instructions', id], true)
.catch(e => console.error('Failed to mark instructions as seen', e))
this.dialogs
.open(MARKDOWN, {
label: `${title} instructions`,
size: 'l',
data: {
content: from(this.api.getStaticInstalled(id, 'instructions.md')),
},
})
.subscribe()
}
}

View File

@@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
Component,
inject,
INJECTOR,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { CopyService, getPkgId } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import {
FALLBACK_URL,
ServiceAdditionalItemComponent,
} from '../components/additional-item.component'
import ServiceMarkdownRoute from './markdown.component'
@Component({
template: `
<section class="g-card">
@for (additional of items(); track $index) {
@if (additional.description.startsWith('http')) {
<a tuiCell [additionalItem]="additional"></a>
} @else {
<button
tuiCell
[style.pointer-events]="!additional.icon ? 'none' : null"
[additionalItem]="additional"
(click)="additional.action?.()"
></button>
}
}
</section>
`,
styles: `
section {
display: flex;
flex-direction: column;
max-width: 32rem;
padding: 0.75rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: { class: 'g-subpage' },
imports: [ServiceAdditionalItemComponent, TuiCell],
})
export default class ServiceAboutRoute {
private readonly copyService = inject(CopyService)
private readonly markdown = inject(TuiDialogService).open(
new PolymorpheusComponent(ServiceMarkdownRoute, inject(INJECTOR)),
{ label: 'License', size: 'l' },
)
readonly items = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
map(pkg => {
const manifest = getManifest(pkg)
return [
{
name: 'Version',
description: manifest.version,
},
{
name: 'Git Hash',
description: manifest.gitHash || 'Unknown',
icon: manifest.gitHash ? '@tui.copy' : '',
action: () =>
manifest.gitHash && this.copyService.copy(manifest.gitHash),
},
{
name: 'License',
description: manifest.license,
icon: '@tui.chevron-right',
action: () => this.markdown.subscribe(),
},
{
name: 'Website',
description: manifest.marketingSite || FALLBACK_URL,
},
{
name: 'Donation Link',
description: manifest.donationUrl || FALLBACK_URL,
},
{
name: 'Source Repository',
description: manifest.upstreamRepo,
},
{
name: 'Support Site',
description: manifest.supportSite || FALLBACK_URL,
},
{
name: 'Registry',
description: pkg.registry || FALLBACK_URL,
},
{
name: 'Binary Source',
description: manifest.wrapperRepo,
},
]
}),
),
)
}

View File

@@ -1,53 +1,75 @@
import { KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { ActionService } from 'src/app/services/action.service'
import { ServiceActionComponent } from '../components/action.component'
const OTHER = 'Other Custom Actions'
@Component({
template: `
@if (package(); as pkg) {
<section>
<h3 class="g-title">Standard Actions</h3>
<section class="g-card">
<header>Standard Actions</header>
<button
class="g-action"
tuiCell
[action]="rebuild"
(click)="service.rebuild(pkg.manifest.id)"
></button>
<button
class="g-action"
tuiCell
[action]="uninstall"
(click)="service.uninstall(pkg.manifest)"
></button>
</section>
@if (pkg.actions.length) {
<h3 class="g-title">Actions for {{ pkg.manifest.title }}</h3>
}
@for (action of pkg.actions; track $index) {
@if (action.visibility !== 'hidden') {
<button
class="g-action"
[action]="action"
(click)="
handleAction(pkg.mainStatus, pkg.icon, pkg.manifest, action)
"
></button>
@for (group of pkg.actions | keyvalue; track $index) {
@if (group.value.length) {
<section class="g-card">
<header>{{ group.key }}</header>
@for (a of group.value; track $index) {
@if (a.visibility !== 'hidden') {
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
></button>
}
}
</section>
}
}
}
`,
styles: `
section {
max-width: 54rem;
display: flex;
flex-direction: column;
margin-bottom: 2rem;
}
[tuiCell] {
margin: 0 -1rem;
&:last-child {
margin-bottom: -0.75rem;
}
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ServiceActionComponent],
imports: [ServiceActionComponent, TuiCell, KeyValuePipe],
})
export class ServiceActionsRoute {
export default class ServiceActionsRoute {
private readonly actions = inject(ActionService)
readonly service = inject(StandardActionsService)
@@ -60,10 +82,18 @@ export class ServiceActionsRoute {
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.keys(pkg.actions).map(id => ({
id,
...pkg.actions[id],
})),
actions: Object.keys(pkg.actions).reduce<
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>(
(acc, id) => {
const action = { id, ...pkg.actions[id] }
const group = pkg.actions[id].group || OTHER
const current = acc[group] || []
return { ...acc, [group]: current.concat(action) }
},
{ [OTHER]: [] },
),
})),
),
)
@@ -71,14 +101,14 @@ export class ServiceActionsRoute {
readonly rebuild = REBUILD
readonly uninstall = UNINSTALL
handleAction(
handle(
mainStatus: T.MainStatus['main'],
icon: string,
manifest: T.Manifest,
{ id, title }: T.Manifest,
action: T.ActionMetadata & { id: string },
) {
this.actions.present({
pkgInfo: { id: manifest.id, title: manifest.title, icon, mainStatus },
pkgInfo: { id, title, icon, mainStatus },
actionInfo: { id: action.id, metadata: action },
})
}

View File

@@ -13,15 +13,16 @@ import { ConfigService } from 'src/app/services/config.service'
template: `
<app-interface
*ngIf="interfacesWithAddresses$ | async as serviceInterface"
[packageContext]="context"
[packageId]="context.packageId"
[serviceInterface]="serviceInterface"
/>
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfaceComponent],
})
export class ServiceInterfaceRoute {
export default class ServiceInterfaceRoute {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly config = inject(ConfigService)

View File

@@ -8,10 +8,10 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
template: '<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id" />',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
styles: [':host { height: 100%}'],
host: { class: 'g-subpage' },
imports: [LogsComponent],
})
export class ServiceLogsRoute {
export default class ServiceLogsRoute {
private readonly api = inject(ApiService)
readonly id = getPkgId()

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute } from '@angular/router'
import {
getErrorMessage,
MarkdownPipeModule,
SafeLinksDirective,
} from '@start9labs/shared'
import { TuiLoader, TuiNotification } from '@taiga-ui/core'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { catchError, ignoreElements, of } from 'rxjs'
@Component({
template: `
@if (error()) {
<tui-notification appearance="negative" safeLinks>
{{ error() }}
</tui-notification>
}
@if (content(); as result) {
<div safeLinks [innerHTML]="result | markdown | dompurify"></div>
} @else {
<tui-loader textContent="Loading" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
host: { class: 'g-subpage' },
imports: [
TuiNotification,
TuiLoader,
MarkdownPipeModule,
NgDompurifyModule,
SafeLinksDirective,
],
})
export default class ServiceMarkdownRoute {
private readonly data = inject(ActivatedRoute).snapshot.data
readonly content = toSignal<string>(this.data['content'])
readonly error = toSignal(
this.data['content'].pipe(
ignoreElements(),
catchError(e => of(getErrorMessage(e))),
),
)
}

View File

@@ -1,35 +1,170 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { TuiAppearance, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar, TuiFade } from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
const ICONS = {
dashboard: '@tui.layout-dashboard',
actions: '@tui.clapperboard',
instructions: '@tui.book-open-text',
logs: '@tui.logs',
about: '@tui.info',
}
@Component({
template: `
<ng-container *ngIf="service$ | async" />
@if (service()) {
<aside>
<header tuiCell>
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
<span tuiTitle>
<strong tuiFade>{{ manifest()?.title }}</strong>
<span tuiSubtitle>{{ manifest()?.version }}</span>
</span>
</header>
<nav>
@for (item of nav; track $index) {
<a
tuiCell
tuiAppearance="action-grayscale"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="item === 'dashboard' ? './' : item"
>
<tui-icon [icon]="icons[item]" />
<span tuiTitle>{{ item }}</span>
</a>
}
</nav>
</aside>
}
<router-outlet />
`,
host: { class: 'g-page' },
styles: `
:host {
display: flex;
padding: 0;
}
aside {
position: sticky;
top: 1px;
left: 1px;
margin: 1px;
width: 16rem;
padding: 0.5rem;
text-transform: capitalize;
box-shadow: 1px 0 var(--tui-border-normal);
backdrop-filter: blur(1rem);
background-color: color-mix(
in hsl,
var(--tui-background-base) 90%,
transparent
);
}
header {
margin: 0 -0.5rem;
}
.active {
color: var(--tui-text-primary);
}
:host-context(tui-root._mobile) {
flex-direction: column;
padding: 0;
aside {
top: 0;
left: 0;
width: 100%;
padding: 0;
margin: 0;
z-index: 1;
box-shadow: inset 0 1px 0 1px var(--tui-background-neutral-1);
header {
display: none;
}
nav {
display: flex;
}
a {
flex: 1;
justify-content: center;
border-radius: 0;
background: var(--tui-background-neutral-1);
&.active {
background: none;
}
[tuiTitle] {
display: none;
}
}
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, RouterOutlet],
imports: [
RouterModule,
TuiCell,
TuiAvatar,
TuiTitle,
TuiAppearance,
TuiIcon,
TuiFade,
],
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
})
export class ServiceOutletComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly params = inject(ActivatedRoute).paramMap
readonly service$ = this.router.events.pipe(
map(() => this.route.firstChild?.snapshot.paramMap?.get('pkgId')),
filter(Boolean),
distinctUntilChanged(),
switchMap(id => this.patch.watch$('packageData', id)),
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/services'])
}
}),
protected readonly icons = ICONS
protected readonly nav = [
'dashboard',
'actions',
'instructions',
'logs',
'about',
] as const
protected readonly service = toSignal(
this.router.events.pipe(
switchMap(() => this.params),
map(params => params.get('pkgId')),
filter(Boolean),
distinctUntilChanged(),
switchMap(id => this.patch.watch$('packageData', id)),
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/services'])
}
}),
),
)
protected readonly manifest = computed(
(pkg = this.service()) => pkg && getManifest(pkg),
)
}

View File

@@ -1,197 +1,89 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute } from '@angular/router'
import { isEmptyObject } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, switchMap } from 'rxjs'
import { ServiceBackupsComponent } from 'src/app/routes/portal/routes/services/components/backups.component'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { map } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import {
DepErrorService,
PkgDependencyErrors,
} from 'src/app/services/dep-error.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryRendering,
renderPkgStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { DependentInfo } from 'src/app/types/dependent-info'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionRequestComponent } from '../components/action-request.component'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { ServiceActionRequestsComponent } from '../components/action-requests.component'
import { ServiceActionsComponent } from '../components/actions.component'
import { ServiceDependenciesComponent } from '../components/dependencies.component'
import { ServiceErrorComponent } from '../components/error.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceInterfaceListComponent } from '../components/interface-list.component'
import { ServiceMenuComponent } from '../components/menu.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
import { ToActionRequestsPipe } from '../pipes/to-action-requests.pipe'
import { DependencyInfo } from '../types/dependency-info'
@Component({
template: `
@if (service$ | async; as service) {
<section [style.grid-column]="'span 3'">
<h3>Status</h3>
<service-status
[connected]="!!(connected$ | async)"
[installingInfo]="service.pkg.stateInfo.installingInfo"
[rendering]="getRendering(service.status)"
/>
<service-status
[connected]="!!connected()"
[installingInfo]="pkg().stateInfo.installingInfo"
[status]="status()"
>
@if (installed() && connected()) {
<service-actions [pkg]="pkg()" [status]="status()" />
}
</service-status>
@if (isInstalled(service) && (connected$ | async)) {
<service-actions [service]="service" />
}
</section>
@if (isInstalled(service)) {
<section [style.grid-column]="'span 3'">
<h3>Backups</h3>
<service-backups [pkg]="service.pkg" />
</section>
@if (service.pkg.status.main === 'error') {
<section class="error">
<h3>Error</h3>
<service-error [pkg]="service.pkg" />
</section>
} @else {
<section [style.grid-column]="'span 6'">
<h3>Metrics</h3>
TODO
</section>
}
<section [style.grid-column]="'span 4'" [style.align-self]="'start'">
<h3>Menu</h3>
<service-menu [pkg]="service.pkg" />
</section>
<div>
@if (service.pkg | toActionRequests: service.allPkgs; as requests) {
@if (requests.critical.length) {
<section>
<h3>Required Actions</h3>
@for (request of requests.critical; track $index) {
<button
[actionRequest]="request"
[pkg]="service.pkg"
[allPkgs]="service.allPkgs"
></button>
}
</section>
}
@if (requests.important.length) {
<section>
<h3>Requested Actions</h3>
@for (request of requests.important; track $index) {
<button
[actionRequest]="request"
[pkg]="service.pkg"
[allPkgs]="service.allPkgs"
></button>
}
</section>
}
}
<section>
<h3>Health Checks</h3>
<service-health-checks [checks]="(health$ | async) || []" />
</section>
<section>
<h3>Dependencies</h3>
<service-dependencies [dependencies]="service.dependencies" />
</section>
</div>
<section [style.grid-column]="'span 4'" [style.align-self]="'start'">
<h3>Service Interfaces</h3>
<service-interface-list
[pkg]="service.pkg"
[status]="service.status"
/>
</section>
@if (installed()) {
@if (pkg().status.main === 'error') {
<service-error [pkg]="pkg()" />
}
@if (isInstalling(service.pkg.stateInfo.state)) {
@for (
item of service.pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<p [progress]="item.progress">{{ item.name }}</p>
}
<service-interfaces [pkg]="pkg()" [disabled]="status() !== 'running'" />
<service-dependencies [pkg]="pkg()" [services]="services()" />
<service-health-checks [checks]="health()" />
<section class="g-card">
<header>Tasks</header>
<service-action-requests [pkg]="pkg()" />
</section>
}
@if (installing()) {
@for (
item of pkg().stateInfo.installingInfo?.progress?.phases;
track $index
) {
<p [progress]="item.progress">{{ item.name }}</p>
}
}
`,
styles: `
:host {
display: grid;
grid-template-columns: repeat(12, 1fr);
flex-direction: column;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: max-content;
gap: 1rem;
margin: 1rem -1rem 0;
}
small {
font-weight: normal;
text-transform: uppercase;
}
:host-context(tui-root._mobile) {
display: flex;
}
grid-template-columns: 1fr;
section {
display: flex;
flex-direction: column;
width: 100%;
padding: 1rem 1.5rem 0.5rem;
border-radius: 0.5rem;
background: var(--tui-background-neutral-1);
box-shadow: inset 0 7rem 0 -4rem var(--tui-background-neutral-1);
clip-path: polygon(0 1.5rem, 1.5rem 0, 100% 0, 100% 100%, 0 100%);
&.error {
box-shadow: inset 0 7rem 0 -4rem var(--tui-status-negative-pale);
grid-column: span 6;
h3 {
color: var(--tui-status-negative);
}
> * {
grid-column: span 1 !important;
}
::ng-deep [tuiCell] {
width: stretch;
margin: 0 -1rem;
&:not(:last-child) {
box-shadow: 0 0.51rem 0 -0.5rem;
}
}
}
h3 {
margin-bottom: 1.25rem;
}
div {
display: flex;
flex-direction: column;
gap: inherit;
grid-column: span 4;
}
:host-context(tui-root._mobile) {
margin: 0;
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -199,193 +91,51 @@ import { DependencyInfo } from '../types/dependency-info'
ServiceProgressComponent,
ServiceStatusComponent,
ServiceActionsComponent,
ServiceInterfaceListComponent,
ServiceInterfacesComponent,
ServiceHealthChecksComponent,
ServiceDependenciesComponent,
ServiceMenuComponent,
ServiceBackupsComponent,
ServiceActionRequestComponent,
ServiceErrorComponent,
ToActionRequestsPipe,
InstallingProgressPipe,
ServiceActionRequestsComponent,
],
})
export class ServiceRoute {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly pkgId$ = inject(ActivatedRoute).paramMap.pipe(
map(params => params.get('pkgId')!),
)
private readonly depErrorService = inject(DepErrorService)
private readonly router = inject(Router)
private readonly formDialog = inject(FormDialogService)
readonly connected$ = inject(ConnectionService)
protected readonly connected = toSignal(inject(ConnectionService))
readonly service$ = this.pkgId$.pipe(
switchMap(pkgId =>
combineLatest([
this.patch.watch$('packageData'),
this.depErrorService.getPkgDepErrors$(pkgId),
]).pipe(
map(([allPkgs, depErrors]) => {
const pkg = allPkgs[pkgId]
return {
allPkgs,
pkg,
dependencies: this.getDepInfo(pkg, depErrors),
status: renderPkgStatus(pkg, depErrors),
}
}),
),
),
protected readonly id = toSignal(
inject(ActivatedRoute).paramMap.pipe(map(params => params.get('pkgId'))),
)
readonly health$ = this.pkgId$.pipe(
switchMap(pkgId => this.patch.watch$('packageData', pkgId, 'status')),
map(toHealthCheck),
protected readonly services = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData'),
{ initialValue: {} as Record<string, PackageDataEntry> },
)
isInstalling(state: string): boolean {
return (
state === 'installing' || state === 'updating' || state === 'restoring'
)
}
protected readonly pkg = computed(() => this.services()[this.id() || ''])
isInstalled({ pkg, status }: any): boolean {
return pkg.stateInfo.state === 'installed' && status.primary !== 'backingUp'
}
protected readonly health = computed(() =>
this.pkg() ? toHealthCheck(this.pkg().status) : [],
)
getRendering({ primary }: PackageStatus): StatusRendering {
return PrimaryRendering[primary]
}
protected readonly status = computed((pkg = this.pkg()) =>
pkg?.stateInfo.state === 'installed'
? getInstalledPrimaryStatus(pkg)
: pkg?.stateInfo.state,
)
private getDepInfo(
pkg: PackageDataEntry,
depErrors: PkgDependencyErrors,
): DependencyInfo[] {
const manifest = getManifest(pkg)
protected readonly installed = computed(
() =>
this.pkg()?.stateInfo.state === 'installed' &&
this.status() !== 'backingUp',
)
return Object.keys(pkg.currentDependencies).map(id =>
this.getDepValues(pkg, manifest, id, depErrors),
)
}
private getDepValues(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,
depId: string,
depErrors: PkgDependencyErrors,
): DependencyInfo {
const { errorText, fixText, fixAction } = this.getDepErrors(
pkg,
pkgManifest,
depId,
depErrors,
)
const { title, icon, versionRange } = pkg.currentDependencies[depId]
return {
id: depId,
version: versionRange,
title,
icon,
errorText: errorText
? `${errorText}. ${pkgManifest.title} will not work as expected.`
: '',
actionText: fixText || 'View',
action:
fixAction ||
(() => {
this.router.navigate([`portal`, `service`, depId])
}),
}
}
private getDepErrors(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,
depId: string,
depErrors: PkgDependencyErrors,
) {
const depError = depErrors[depId]
let errorText: string | null = null
let fixText: string | null = null
let fixAction: (() => any) | null = null
if (depError) {
if (depError.type === 'notInstalled') {
errorText = 'Not installed'
fixText = 'Install'
fixAction = () => this.fixDep(pkg, pkgManifest, 'install', depId)
} else if (depError.type === 'incorrectVersion') {
errorText = 'Incorrect version'
fixText = 'Update'
fixAction = () => this.fixDep(pkg, pkgManifest, 'update', depId)
} else if (depError.type === 'actionRequired') {
errorText = 'Action Required (see above)'
} else if (depError.type === 'notRunning') {
errorText = 'Not running'
fixText = 'Start'
} else if (depError.type === 'healthChecksFailed') {
errorText = 'Required health check not passing'
} else if (depError.type === 'transitive') {
errorText = 'Dependency has a dependency issue'
}
}
return {
errorText,
fixText,
fixAction,
}
}
async fixDep(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,
action: 'install' | 'update' | 'configure',
depId: string,
): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg, pkgManifest, depId)
case 'configure':
// return this.formDialog.open<PackageConfigData>(ConfigModal, {
// label: `${pkg.currentDependencies[depId].title} config`,
// data: {
// pkgId: depId,
// dependentInfo: pkgManifest,
// },
// })
}
}
private async installDep(
pkg: PackageDataEntry,
manifest: T.Manifest,
depId: string,
): Promise<void> {
const dependentInfo: DependentInfo = {
id: manifest.id,
title: manifest.title,
version: pkg.currentDependencies[depId].versionRange,
}
const navigationExtras: NavigationExtras = {
// @TODO state not being used by marketplace component. Maybe it is not important to use.
state: { dependentInfo },
queryParams: { id: depId },
}
await this.router.navigate(['portal', 'marketplace'], navigationExtras)
}
protected readonly installing = computed(
(state = this.status()) =>
state === 'installing' || state === 'updating' || state === 'restoring',
)
}
function toHealthCheck(
status: T.MainStatus,
): T.NamedHealthCheckResult[] | null {
function toHealthCheck(status: T.MainStatus): T.NamedHealthCheckResult[] {
return status.main !== 'running' || isEmptyObject(status.health)
? null
? []
: Object.values(status.health)
}

View File

@@ -1,46 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { ServiceOutletComponent } from './routes/outlet.component'
import { ServiceRoute } from './routes/service.component'
const ROUTES: Routes = [
{
path: '',
component: ServiceOutletComponent,
children: [
{
path: ':pkgId',
component: ServiceRoute,
},
{
path: ':pkgId/actions',
loadComponent: () =>
import('./routes/actions.component').then(m => m.ServiceActionsRoute),
},
{
path: ':pkgId/interface/:interfaceId',
loadComponent: () =>
import('./routes/interface.component').then(
m => m.ServiceInterfaceRoute,
),
},
{
path: ':pkgId/logs',
loadComponent: () =>
import('./routes/logs.component').then(m => m.ServiceLogsRoute),
},
{
path: '',
pathMatch: 'full',
loadComponent: () =>
import('./dashboard/dashboard.component').then(
m => m.DashboardComponent,
),
},
],
},
]
@NgModule({ imports: [RouterModule.forChild(ROUTES)] })
export class ServicesModule {}

View File

@@ -0,0 +1,72 @@
import { inject } from '@angular/core'
import { ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router'
import { defer, map, Observable, of } from 'rxjs'
import { share } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServiceOutletComponent } from './routes/outlet.component'
import { ServiceRoute } from './routes/service.component'
export const ROUTES: Routes = [
{
path: ':pkgId',
component: ServiceOutletComponent,
children: [
{
path: '',
component: ServiceRoute,
},
{
path: 'actions',
loadComponent: () => import('./routes/actions.component'),
},
{
path: 'instructions',
loadComponent: () => import('./routes/markdown.component'),
resolve: { content: getStatic('instructions.md') },
canActivate: [
({ paramMap }: ActivatedRouteSnapshot) => {
inject(ApiService)
.setDbValue(['ack-instructions', paramMap.get('pkgId')!], true)
.catch(e => console.error('Failed to mark as seen', e))
return true
},
],
},
{
path: 'interface/:interfaceId',
loadComponent: () => import('./routes/interface.component'),
},
{
path: 'logs',
loadComponent: () => import('./routes/logs.component'),
},
{
path: 'about',
loadComponent: () => import('./routes/about.component'),
resolve: { content: getStatic('LICENSE.md') },
},
],
},
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./dashboard/dashboard.component'),
},
]
function getStatic(
path: 'LICENSE.md' | 'instructions.md',
): ResolveFn<Observable<string>> {
return ({ paramMap }: ActivatedRouteSnapshot) =>
of(inject(ApiService)).pipe(
map(api =>
defer(() => api.getStaticInstalled(paramMap.get('pkgId')!, path)).pipe(
share(),
),
),
)
}
export default ROUTES

View File

@@ -0,0 +1,16 @@
import { T } from '@start9labs/start-sdk'
export type MappedInterface = T.ServiceInterface & {
public: boolean
// TODO implement addresses
addresses: any
routerLink: string
}
export type MappedAddress = {
name: string
url: string
isDomain: boolean
isOnion: boolean
acme: string | null
}

View File

@@ -22,6 +22,7 @@ import { SettingsUpdateComponent } from './update.component'
/>
<ng-container *ngFor="let btn of cat.value">
<settings-button [button]="btn">
<!-- // @TODO 041
<div
*ngIf="btn.title === 'Outbound Proxy'"
tuiSubtitle
@@ -32,7 +33,7 @@ import { SettingsUpdateComponent } from './update.component'
"
>
{{ server.network.outboundProxy || 'None' }}
</div>
</div> -->
</settings-button>
</ng-container>
</section>

View File

@@ -19,7 +19,7 @@ const iface: T.ServiceInterface = {
type: 'ui' as const,
masked: false,
addressInfo: {
hostId: '',
hostId: 'startos-ui',
internalPort: 80,
scheme: 'http',
sslScheme: 'https',

View File

@@ -21,7 +21,6 @@ import { switchMap } from 'rxjs/operators'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { AuthService } from 'src/app/services/auth.service'
import { ProxyService } from 'src/app/services/proxy.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { FormDialogService } from 'src/app/services/form-dialog.service'
@@ -35,7 +34,6 @@ import { ConfigService } from 'src/app/services/config.service'
export class SettingsService {
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService)
private readonly proxyService = inject(ProxyService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)

View File

@@ -45,7 +45,7 @@ import { parseS9pk } from './sideload.utils'
@if (error()) {
<div>
<tui-avatar appearance="secondary" src="@tui.circle-x" />
<p class="g-error">{{ error() }}</p>
<p class="g-negative">{{ error() }}</p>
<button tuiButton>Try again</button>
</div>
} @else {
@@ -53,7 +53,7 @@ import { parseS9pk } from './sideload.utils'
<tui-avatar appearance="secondary" src="@tui.cloud-upload" />
<p>Upload .s9pk package file</p>
@if (isTor) {
<p class="g-success">Tip: switch to LAN for faster uploads</p>
<p class="g-positive">Tip: switch to LAN for faster uploads</p>
}
<button tuiButton>Upload</button>
</div>

View File

@@ -55,7 +55,7 @@
// </div>
// @if (localPkg.stateInfo.state === 'updating') {
// <tui-progress-circle
// class="g-success"
// class="g-positive"
// size="s"
// [max]="1"
// [value]="

View File

@@ -28,7 +28,7 @@
// {{ host.name }}
// </h3>
// @if (data.errors.includes(host.url)) {
// <p class="g-error">Request Failed</p>
// <p class="g-negative">Request Failed</p>
// }
// @if (data.mp[host.url]?.packages | filterUpdates: data.local; as pkgs) {
// @for (pkg of pkgs; track pkg) {

View File

@@ -37,6 +37,7 @@ const routes: Routes = [
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
paramsInheritanceStrategy: 'always',
preloadingStrategy: PreloadAllModules,
initialNavigation: 'disabled',
}),

View File

@@ -1,7 +1,6 @@
import {
InstalledState,
PackageDataEntry,
ServerStatusInfo,
} from 'src/app/services/patch-db/data-model'
import { RR, ServerMetrics, ServerNotifications } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON, REGISTRY_ICON } from './api-icons'
@@ -22,14 +21,15 @@ const mockDescription = {
long: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
}
export module Mock {
export const ServerUpdated: ServerStatusInfo = {
currentBackup: null,
export namespace Mock {
export const ServerUpdated: T.ServerStatus = {
backupProgress: null,
updateProgress: null,
updated: true,
restarting: false,
shuttingDown: false,
}
export const MarketplaceEos: RR.CheckOSUpdateRes = {
version: '0.3.6',
headline: 'Our biggest release ever.',
@@ -1012,131 +1012,192 @@ export module Mock {
}
export const BackupTargets: RR.GetBackupTargetsRes = {
unknownDisks: [
{
logicalname: 'sbc4',
label: 'My Backup Drive',
capacity: 2000000000000,
used: 100000000000,
model: 'T7',
vendor: 'Samsung',
startOs: {},
},
],
saved: {
hsbdjhasbasda: {
type: 'cifs',
name: 'Embassy Backups',
hostname: 'smb://192.169.10.0',
path: '/Desktop/embassy-backups',
username: 'TestUser',
mountable: true,
startOs: {
abcdefgh: {
hostname: 'adjective-noun.local',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
hsbdjhasbasda: {
type: 'cifs',
hostname: 'smb://192.169.10.0',
path: '/Desktop/startos-backups',
username: 'TestUser',
mountable: false,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.3.6',
passwordHash:
// password is asdfasdf
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
},
ftcvewdnkemfksdm: {
type: 'cloud',
name: 'Dropbox 1',
provider: 'dropbox',
path: '/Home/backups',
mountable: false,
startOs: {},
},
csgashbdjkasnd: {
type: 'cifs',
name: 'Network Folder 2',
hostname: 'smb://192.169.10.0',
path: '/Desktop/embassy-backups-2',
username: 'TestUser',
mountable: true,
startOs: {},
},
powjefhjbnwhdva: {
type: 'disk',
name: 'Physical Drive 1',
logicalname: 'sdba1',
label: 'Another Drive',
capacity: 2000000000000,
used: 100000000000,
model: null,
vendor: 'SSK',
mountable: true,
path: '/HomeFolder/Documents',
startOs: {
'different-server': {
hostname: 'different-server.local',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
},
// 'ftcvewdnkemfksdm': {
// type: 'disk',
// logicalname: 'sdba1',
// label: 'Matt Stuff',
// capacity: 1000000000000,
// used: 0,
// model: 'Evo SATA 2.5',
// vendor: 'Samsung',
// startOs: {},
// },
csgashbdjkasnd: {
type: 'cifs',
hostname: 'smb://192.169.10.0',
path: '/Desktop/startos-backups-2',
username: 'TestUser',
mountable: true,
startOs: {},
},
powjefhjbnwhdva: {
type: 'disk',
logicalname: 'sdba1',
label: 'Another Drive',
capacity: 2000000000000,
used: 100000000000,
model: null,
vendor: 'SSK',
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.3.6',
passwordHash:
// password is asdfasdf
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
},
},
}
export const BackupJobs: RR.GetBackupJobsRes = [
{
id: 'lalalalalala-babababababa',
name: 'My Backup Job',
targetId: Object.keys(BackupTargets.saved)[0],
cron: '0 3 * * *',
packageIds: ['bitcoind', 'lnd'],
},
{
id: 'hahahahaha-mwmwmwmwmwmw',
name: 'Another Backup Job',
targetId: Object.keys(BackupTargets.saved)[1],
cron: '0 * * * *',
packageIds: ['lnd'],
},
]
// @TODO 041
export const BackupRuns: RR.GetBackupRunsRes = [
{
id: 'kladhbfweubdsk',
startedAt: new Date().toISOString(),
completedAt: new Date(new Date().valueOf() + 10000).toISOString(),
packageIds: ['bitcoind', 'lnd'],
job: BackupJobs[0],
report: {
server: {
attempted: true,
error: null,
},
packages: {
bitcoind: { error: null },
lnd: { error: null },
},
},
},
{
id: 'kladhbfwhrfeubdsk',
startedAt: new Date().toISOString(),
completedAt: new Date(new Date().valueOf() + 10000).toISOString(),
packageIds: ['bitcoind', 'lnd'],
job: BackupJobs[0],
report: {
server: {
attempted: true,
error: null,
},
packages: {
bitcoind: { error: null },
lnd: { error: null },
},
},
},
]
// export const BackupTargets: RR.GetBackupTargetsRes = {
// unknownDisks: [
// {
// logicalname: 'sbc4',
// label: 'My Backup Drive',
// capacity: 2000000000000,
// used: 100000000000,
// model: 'T7',
// vendor: 'Samsung',
// startOs: {},
// },
// ],
// saved: {
// hsbdjhasbasda: {
// type: 'cifs',
// name: 'Embassy Backups',
// hostname: 'smb://192.169.10.0',
// path: '/Desktop/embassy-backups',
// username: 'TestUser',
// mountable: true,
// startOs: {
// abcdefgh: {
// hostname: 'adjective-noun.local',
// version: '0.3.6',
// timestamp: new Date().toISOString(),
// passwordHash:
// '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
// wrappedKey: '',
// },
// },
// },
// ftcvewdnkemfksdm: {
// type: 'cloud',
// name: 'Dropbox 1',
// provider: 'dropbox',
// path: '/Home/backups',
// mountable: false,
// startOs: {},
// },
// csgashbdjkasnd: {
// type: 'cifs',
// name: 'Network Folder 2',
// hostname: 'smb://192.169.10.0',
// path: '/Desktop/embassy-backups-2',
// username: 'TestUser',
// mountable: true,
// startOs: {},
// },
// powjefhjbnwhdva: {
// type: 'disk',
// name: 'Physical Drive 1',
// logicalname: 'sdba1',
// label: 'Another Drive',
// capacity: 2000000000000,
// used: 100000000000,
// model: null,
// vendor: 'SSK',
// mountable: true,
// path: '/HomeFolder/Documents',
// startOs: {
// 'different-server': {
// hostname: 'different-server.local',
// version: '0.3.6',
// timestamp: new Date().toISOString(),
// passwordHash:
// '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
// wrappedKey: '',
// },
// },
// },
// },
// }
// export const BackupJobs: RR.GetBackupJobsRes = [
// {
// id: 'lalalalalala-babababababa',
// name: 'My Backup Job',
// targetId: Object.keys(BackupTargets.saved)[0],
// cron: '0 3 * * *',
// packageIds: ['bitcoind', 'lnd'],
// },
// {
// id: 'hahahahaha-mwmwmwmwmwmw',
// name: 'Another Backup Job',
// targetId: Object.keys(BackupTargets.saved)[1],
// cron: '0 * * * *',
// packageIds: ['lnd'],
// },
// ]
// export const BackupRuns: RR.GetBackupRunsRes = [
// {
// id: 'kladhbfweubdsk',
// startedAt: new Date().toISOString(),
// completedAt: new Date(new Date().valueOf() + 10000).toISOString(),
// packageIds: ['bitcoind', 'lnd'],
// job: BackupJobs[0],
// report: {
// server: {
// attempted: true,
// error: null,
// },
// packages: {
// bitcoind: { error: null },
// lnd: { error: null },
// },
// },
// },
// {
// id: 'kladhbfwhrfeubdsk',
// startedAt: new Date().toISOString(),
// completedAt: new Date(new Date().valueOf() + 10000).toISOString(),
// packageIds: ['bitcoind', 'lnd'],
// job: BackupJobs[0],
// report: {
// server: {
// attempted: true,
// error: null,
// },
// packages: {
// bitcoind: { error: null },
// lnd: { error: null },
// },
// },
// },
// ]
export const BackupInfo: RR.GetBackupInfoRes = {
version: '0.3.6',
@@ -1819,9 +1880,7 @@ export module Mock {
},
dataVersion: MockManifestBitcoind.version,
icon: '/assets/img/service-icons/bitcoind.svg',
installedAt: new Date().toISOString(),
lastBackup: null,
nextBackup: null,
status: {
main: 'running',
started: new Date().toISOString(),
@@ -2065,7 +2124,6 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
requestedActions: {
'bitcoind-config': {
request: {
@@ -2096,9 +2154,7 @@ export module Mock {
},
dataVersion: MockManifestBitcoinProxy.version,
icon: '/assets/img/service-icons/btc-rpc-proxy.png',
installedAt: new Date().toISOString(),
lastBackup: null,
nextBackup: null,
status: {
main: 'stopped',
},
@@ -2133,7 +2189,6 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
requestedActions: {},
}
@@ -2144,9 +2199,7 @@ export module Mock {
},
dataVersion: MockManifestLnd.version,
icon: '/assets/img/service-icons/lnd.png',
installedAt: new Date().toISOString(),
lastBackup: null,
nextBackup: null,
status: {
main: 'stopped',
},
@@ -2239,7 +2292,6 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
requestedActions: {
config: {
active: true,

View File

@@ -1,12 +1,10 @@
import { DomainInfo } from 'src/app/services/patch-db/data-model'
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo } from '@start9labs/shared'
import { StartOSDiskInfo, FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import { IST, T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export module RR {
export namespace RR {
// websocket
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
@@ -70,7 +68,7 @@ export module RR {
uptime: number // seconds
}
export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs & server.tor-logs
export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = FetchLogsRes
export type FollowServerLogsReq = {
@@ -83,6 +81,7 @@ export module RR {
guid: string
}
// @TODO 040 implement websocket
export type FollowServerMetricsReq = {} // server.metrics.follow
export type FollowServerMetricsRes = {
guid: string
@@ -92,9 +91,6 @@ export module RR {
export type UpdateServerReq = { registry: string } // server.update
export type UpdateServerRes = 'updating' | 'no-updates'
export type SetServerClearnetAddressReq = { domainInfo: DomainInfo | null } // server.set-clearnet
export type SetServerClearnetAddressRes = null
export type RestartServerReq = {} // server.restart
export type RestartServerRes = null
@@ -110,11 +106,6 @@ export module RR {
} // net.tor.reset
export type ResetTorRes = null
export type SetOsOutboundProxyReq = {
proxy: string | null
} // server.proxy.set-outbound
export type SetOsOutboundProxyRes = null
// smtp
export type SetSMTPReq = T.SmtpValue // server.set-smtp
@@ -139,18 +130,13 @@ export module RR {
// notification
export type FollowNotificationsReq = {}
export type FollowNotificationsRes = {
notifications: ServerNotifications
guid: string
}
export type GetNotificationsReq = {
before?: number
limit?: number
} // notification.list
export type GetNotificationsRes = ServerNotification<number>[]
// @TODO 040 all these notification endpoints need updating
export type DeleteNotificationReq = { ids: number[] } // notification.delete
export type DeleteNotificationRes = null
@@ -163,51 +149,12 @@ export module RR {
export type MarkUnseenNotificationReq = DeleteNotificationReq // notification.mark-unseen
export type MarkUnseenNotificationRes = null
// network
export type AddProxyReq = {
name: string
config: string
} // net.proxy.add
export type AddProxyRes = null
export type UpdateProxyReq = {
name: string
} // net.proxy.update
export type UpdateProxyRes = null
export type DeleteProxyReq = { id: string } // net.proxy.delete
export type DeleteProxyRes = null
// domains
export type ClaimStart9ToReq = { networkInterfaceId: string } // net.domain.me.claim
export type ClaimStart9ToRes = null
export type DeleteStart9ToReq = {} // net.domain.me.delete
export type DeleteStart9ToRes = null
export type AddDomainReq = {
hostname: string
provider: {
name: string
username: string | null
password: string | null
}
networkInterfaceId: string
} // net.domain.add
export type AddDomainRes = null
export type DeleteDomainReq = { hostname: string } // net.domain.delete
export type DeleteDomainRes = null
// port forwards
export type OverridePortReq = { target: number; port: number } // net.port-forwards.override
export type OverridePortRes = null
// wifi
// @TODO remove for 040, set at server scope
// export type SetWifiCountryReq = { country: string }
// export type SetWifiCountryRes = null
export type GetWifiReq = {}
export type GetWifiRes = {
ssids: {
@@ -228,23 +175,16 @@ export module RR {
}
export type AddWifiRes = null
// @TODO 040
export type EnableWifiReq = { enable: boolean } // wifi.enable
export type EnableWifiRes = null
export type ConnectWifiReq = { ssid: string } // wifi.connect
export type ConnectWifiRes = null
export type DeleteWifiReq = { ssid: string } // wifi.delete
export type DeleteWifiReq = { ssid: string } // wifi.remove
export type DeleteWifiRes = null
// email
export type ConfigureEmailReq = T.SmtpValue // email.configure
export type ConfigureEmailRes = null
export type TestEmailReq = ConfigureEmailReq & { to: string } // email.test
export type TestEmailRes = null
// ssh
export type GetSSHKeysReq = {} // ssh.list
@@ -253,84 +193,44 @@ export module RR {
export type AddSSHKeyReq = { key: string } // ssh.add
export type AddSSHKeyRes = SSHKey
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.delete
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.remove
export type DeleteSSHKeyRes = null
// backup
export type GetBackupTargetsReq = {} // backup.target.list
export type GetBackupTargetsRes = {
unknownDisks: UnknownDisk[]
saved: Record<string, BackupTarget>
}
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
export type AddCifsBackupTargetReq = {
name: string
path: string
export type AddBackupTargetReq = {
// backup.target.cifs.add
hostname: string
path: string
username: string
password?: string
} // backup.target.cifs.add
export type AddCloudBackupTargetReq = {
name: string
path: string
provider: CloudProvider
[params: string]: any
} // backup.target.cloud.add
export type AddDiskBackupTargetReq = {
logicalname: string
name: string
path: string
} // backup.target.disk.add
export type AddBackupTargetRes = Record<string, BackupTarget>
password: string | null
}
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
export type UpdateCifsBackupTargetReq = AddCifsBackupTargetReq & {
id: string
} // backup.target.cifs.update
export type UpdateCloudBackupTargetReq = AddCloudBackupTargetReq & {
id: string
} // backup.target.cloud.update
export type UpdateDiskBackupTargetReq = Omit<
AddDiskBackupTargetReq,
'logicalname'
> & {
id: string
} // backup.target.disk.update
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type RemoveBackupTargetReq = { id: string } // backup.target.remove
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null
export type GetBackupJobsReq = {} // backup.job.list
export type GetBackupJobsRes = BackupJob[]
export type CreateBackupJobReq = {
name: string
export type GetBackupInfoReq = {
// backup.target.info
targetId: string
cron: string
packageIds: string[]
now: boolean
} // backup.job.create
export type CreateBackupJobRes = BackupJob
export type UpdateBackupJobReq = Omit<CreateBackupJobReq, 'now'> & {
id: string
} // backup.job.update
export type UpdateBackupJobRes = CreateBackupJobRes
export type DeleteBackupJobReq = { id: string } // backup.job.delete
export type DeleteBackupJobRes = null
export type GetBackupRunsReq = {} // backup.runs
export type GetBackupRunsRes = BackupRun[]
export type DeleteBackupRunsReq = { ids: string[] } // backup.runs.delete
export type DeleteBackupRunsRes = null
export type GetBackupInfoReq = { targetId: string; password: string } // backup.target.info
serverId: string
password: string
}
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = { targetId: string; packageIds: string[] } // backup.create
export type CreateBackupReq = {
// backup.create
targetId: string
packageIds: string[]
oldPassword: string | null
password: string
}
export type CreateBackupRes = null
// package
@@ -375,7 +275,7 @@ export module RR {
private: boolean
acme: string | null // "letsencrypt" | "letsencrypt-staging" | Url | null
}
export type ServerAddDomainRes = null
export type AddDomainRes = null
export type ServerRemoveDomainReq = {
// server.host.address.domain.remove
@@ -409,8 +309,8 @@ export module RR {
host: T.HostId // string
}
export type GetPackageLogsReq = GetServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = GetServerLogsRes
export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = FetchLogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
@@ -458,25 +358,12 @@ export module RR {
export type SideloadPackageReq = {
manifest: T.Manifest
icon: string // base64
size: number // bytes
}
export type SideloadPackageRes = {
upload: string
progress: string
upload: string // guid
progress: string // guid
}
export type SetInterfaceClearnetAddressReq = SetServerClearnetAddressReq & {
packageId: string
interfaceId: string
} // package.interface.set-clearnet
export type SetInterfaceClearnetAddressRes = null
export type SetServiceOutboundProxyReq = {
packageId: string
proxy: string | null
} // package.proxy.set-outbound
export type SetServiceOutboundProxyRes = null
// registry
/** these are returned in ASCENDING order. the newest available version will be the LAST in the object */
@@ -534,20 +421,6 @@ export type ServerMetrics = {
}
}
export type AppMetrics = {
memory: {
percentageUsed: MetricData
used: MetricData
}
cpu: {
percentageUsed: MetricData
}
disk: {
percentageUsed: MetricData
used: MetricData
}
}
export type Session = {
loggedIn: string
lastActive: string
@@ -576,59 +449,41 @@ export type PlatformType =
| 'desktop'
| 'hybrid'
export type RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget
export type BackupTarget = RemoteBackupTarget | DiskBackupTarget
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export type BackupTargetType = 'disk' | 'cifs' | 'cloud'
export interface UnknownDisk {
logicalname: string
export interface DiskBackupTarget {
type: 'disk'
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
startOs: Record<string, StartOSDiskInfo>
}
export interface BaseBackupTarget {
type: BackupTargetType
name: string
mountable: boolean
export interface CifsBackupTarget {
type: 'cifs'
hostname: string
path: string
username: string
mountable: boolean
startOs: Record<string, StartOSDiskInfo>
}
export interface DiskBackupTarget extends UnknownDisk, BaseBackupTarget {
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
export interface DiskRecoverySource {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsBackupTarget extends BaseBackupTarget {
export interface CifsRecoverySource {
type: 'cifs'
hostname: string
path: string
username: string
}
export interface CloudBackupTarget extends BaseBackupTarget {
type: 'cloud'
provider: 'dropbox' | 'google-drive'
}
export type BackupRun = {
id: string
startedAt: string
completedAt: string
packageIds: string[]
job: BackupJob
report: BackupReport
}
export type BackupJob = {
id: string
name: string
targetId: string
cron: string // '* * * * * *' https://cloud.google.com/scheduler/docs/configuring/cron-job-schedules
packageIds: string[]
password: string
}
export type BackupInfo = {
@@ -664,13 +519,16 @@ export type ServerNotification<T extends number> = {
packageId: string | null
createdAt: string
code: T
level: 'success' | 'info' | 'warning' | 'error'
level: NotificationLevel
title: string
message: string
data: NotificationData<T>
// @TODO 040
read: boolean
}
export type NotificationLevel = 'success' | 'info' | 'warning' | 'error'
export type NotificationData<T> = T extends 0
? null
: T extends 1
@@ -716,8 +574,6 @@ export type Encrypted = {
encrypted: string
}
export type CloudProvider = 'dropbox' | 'google-drive'
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
@@ -752,3 +608,213 @@ export type DependencyErrorHealthChecksFailed = {
export type DependencyErrorTransitive = {
type: 'transitive'
}
// **** @TODO 041 ****
// export namespace RR041 {
// // ** domains **
// export type ClaimStart9ToReq = { networkInterfaceId: string } // net.domain.me.claim
// export type ClaimStart9ToRes = null
// export type DeleteStart9ToReq = {} // net.domain.me.delete
// export type DeleteStart9ToRes = null
// export type AddDomainReq = {
// hostname: string
// provider: {
// name: string
// username: string | null
// password: string | null
// }
// networkInterfaceId: string
// } // net.domain.add
// export type AddDomainRes = null
// export type DeleteDomainReq = { hostname: string } // net.domain.delete
// export type DeleteDomainRes = null
// // port forwards
// export type OverridePortReq = { target: number; port: number } // net.port-forwards.override
// export type OverridePortRes = null
// // ** proxies **
// export type AddProxyReq = {
// name: string
// config: string
// } // net.proxy.add
// export type AddProxyRes = null
// export type UpdateProxyReq = {
// name: string
// } // net.proxy.update
// export type UpdateProxyRes = null
// export type DeleteProxyReq = { id: string } // net.proxy.delete
// export type DeleteProxyRes = null
// // ** set outbound proxies **
// export type SetOsOutboundProxyReq = {
// proxy: string | null
// } // server.proxy.set-outbound
// export type SetOsOutboundProxyRes = null
// export type SetServiceOutboundProxyReq = {
// packageId: string
// proxy: string | null
// } // package.proxy.set-outbound
// export type SetServiceOutboundProxyRes = null
// // ** automated backups **
// export type GetBackupTargetsReq = {} // backup.target.list
// export type GetBackupTargetsRes = {
// unknownDisks: UnknownDisk[]
// saved: Record<string, BackupTarget>
// }
// export type AddCifsBackupTargetReq = {
// name: string
// path: string
// hostname: string
// username: string
// password?: string
// } // backup.target.cifs.add
// export type AddCloudBackupTargetReq = {
// name: string
// path: string
// provider: CloudProvider
// [params: string]: any
// } // backup.target.cloud.add
// export type AddDiskBackupTargetReq = {
// logicalname: string
// name: string
// path: string
// } // backup.target.disk.add
// export type AddBackupTargetRes = Record<string, BackupTarget>
// export type UpdateCifsBackupTargetReq = AddCifsBackupTargetReq & {
// id: string
// } // backup.target.cifs.update
// export type UpdateCloudBackupTargetReq = AddCloudBackupTargetReq & {
// id: string
// } // backup.target.cloud.update
// export type UpdateDiskBackupTargetReq = Omit<
// AddDiskBackupTargetReq,
// 'logicalname'
// > & {
// id: string
// } // backup.target.disk.update
// export type UpdateBackupTargetRes = AddBackupTargetRes
// export type RemoveBackupTargetReq = { id: string } // backup.target.remove
// export type RemoveBackupTargetRes = null
// export type GetBackupJobsReq = {} // backup.job.list
// export type GetBackupJobsRes = BackupJob[]
// export type CreateBackupJobReq = {
// name: string
// targetId: string
// cron: string
// packageIds: string[]
// now: boolean
// } // backup.job.create
// export type CreateBackupJobRes = BackupJob
// export type UpdateBackupJobReq = Omit<CreateBackupJobReq, 'now'> & {
// id: string
// } // backup.job.update
// export type UpdateBackupJobRes = CreateBackupJobRes
// export type DeleteBackupJobReq = { id: string } // backup.job.delete
// export type DeleteBackupJobRes = null
// export type GetBackupRunsReq = {} // backup.runs
// export type GetBackupRunsRes = BackupRun[]
// export type DeleteBackupRunsReq = { ids: string[] } // backup.runs.delete
// export type DeleteBackupRunsRes = null
// export type GetBackupInfoReq = { targetId: string; password: string } // backup.target.info
// export type GetBackupInfoRes = BackupInfo
// export type CreateBackupReq = { targetId: string; packageIds: string[] } // backup.create
// export type CreateBackupRes = null
// }
// @TODO 041 types
// export type AppMetrics = {
// memory: {
// percentageUsed: MetricData
// used: MetricData
// }
// cpu: {
// percentageUsed: MetricData
// }
// disk: {
// percentageUsed: MetricData
// used: MetricData
// }
// }
// export type RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget
// export type BackupTarget = RemoteBackupTarget | DiskBackupTarget
// export type BackupTargetType = 'disk' | 'cifs' | 'cloud'
// export interface UnknownDisk {
// logicalname: string
// vendor: string | null
// model: string | null
// label: string | null
// capacity: number
// used: number | null
// startOs: Record<string, StartOSDiskInfo>
// }
// export interface BaseBackupTarget {
// type: BackupTargetType
// name: string
// mountable: boolean
// path: string
// startOs: Record<string, StartOSDiskInfo>
// }
// export interface DiskBackupTarget extends UnknownDisk, BaseBackupTarget {
// type: 'disk'
// }
// export interface CifsBackupTarget extends BaseBackupTarget {
// type: 'cifs'
// hostname: string
// username: string
// }
// export interface CloudBackupTarget extends BaseBackupTarget {
// type: 'cloud'
// provider: 'dropbox' | 'google-drive'
// }
// export type BackupRun = {
// id: string
// startedAt: string
// completedAt: string
// packageIds: string[]
// job: BackupJob
// report: BackupReport
// }
// export type BackupJob = {
// id: string
// name: string
// targetId: string
// cron: string // '* * * * * *' https://cloud.google.com/scheduler/docs/configuring/cron-job-schedules
// packageIds: string[]
// }
// export type CloudProvider = 'dropbox' | 'google-drive'

View File

@@ -5,8 +5,7 @@ import {
} from '@start9labs/marketplace'
import { RPCOptions } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import { BackupTargetType, RR } from './api.types'
import { RR } from './api.types'
import { WebSocketSubject } from 'rxjs/webSocket'
export abstract class ApiService {
@@ -15,8 +14,6 @@ export abstract class ApiService {
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<void>
abstract uploadFile(body: Blob): Promise<string>
// for getting static files: ex icons, instructions, licenses
abstract getStaticProxy(
pkg: MarketplacePkg,
@@ -118,10 +115,6 @@ export abstract class ApiService {
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
abstract setServerClearnetAddress(
params: RR.SetServerClearnetAddressReq,
): Promise<RR.SetServerClearnetAddressRes>
abstract restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes>
@@ -134,9 +127,13 @@ export abstract class ApiService {
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
abstract setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes>
// @TODO 041
// ** server outbound proxy **
// abstract setOsOutboundProxy(
// params: RR.SetOsOutboundProxyReq,
// ): Promise<RR.SetOsOutboundProxyRes>
// smtp
@@ -187,33 +184,39 @@ export abstract class ApiService {
params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes>
// network
// ** proxies **
abstract addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes>
// @TODO 041
abstract updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes>
// abstract addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes>
abstract deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes>
// abstract updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes>
// domains
// abstract deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes>
abstract claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes>
// ** domains **
abstract deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes>
// @TODO 041
abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
// abstract claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes>
abstract deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes>
// abstract deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes>
// port forwards
// abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
abstract overridePortForward(
params: RR.OverridePortReq,
): Promise<RR.OverridePortRes>
// abstract deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes>
// ** port forwards **
// @TODO 041
// abstract overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes>
// wifi
@@ -245,55 +248,71 @@ export abstract class ApiService {
): Promise<RR.GetBackupTargetsRes>
abstract addBackupTarget(
type: BackupTargetType,
params:
| RR.AddCifsBackupTargetReq
| RR.AddCloudBackupTargetReq
| RR.AddDiskBackupTargetReq,
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes>
abstract updateBackupTarget(
type: BackupTargetType,
params:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes>
abstract removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes>
abstract getBackupJobs(
params: RR.GetBackupJobsReq,
): Promise<RR.GetBackupJobsRes>
abstract createBackupJob(
params: RR.CreateBackupJobReq,
): Promise<RR.CreateBackupJobRes>
abstract updateBackupJob(
params: RR.UpdateBackupJobReq,
): Promise<RR.UpdateBackupJobRes>
abstract deleteBackupJob(
params: RR.DeleteBackupJobReq,
): Promise<RR.DeleteBackupJobRes>
abstract getBackupRuns(
params: RR.GetBackupRunsReq,
): Promise<RR.GetBackupRunsRes>
abstract deleteBackupRuns(
params: RR.DeleteBackupRunsReq,
): Promise<RR.DeleteBackupRunsRes>
abstract getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes>
abstract createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
// @TODO 041
// ** automated backups **
// abstract addBackupTarget(
// type: BackupTargetType,
// params:
// | RR.AddCifsBackupTargetReq
// | RR.AddCloudBackupTargetReq
// | RR.AddDiskBackupTargetReq,
// ): Promise<RR.AddBackupTargetRes>
// abstract updateBackupTarget(
// type: BackupTargetType,
// params:
// | RR.UpdateCifsBackupTargetReq
// | RR.UpdateCloudBackupTargetReq
// | RR.UpdateDiskBackupTargetReq,
// ): Promise<RR.UpdateBackupTargetRes>
// abstract removeBackupTarget(
// params: RR.RemoveBackupTargetReq,
// ): Promise<RR.RemoveBackupTargetRes>
// abstract getBackupJobs(
// params: RR.GetBackupJobsReq,
// ): Promise<RR.GetBackupJobsRes>
// abstract createBackupJob(
// params: RR.CreateBackupJobReq,
// ): Promise<RR.CreateBackupJobRes>
// abstract updateBackupJob(
// params: RR.UpdateBackupJobReq,
// ): Promise<RR.UpdateBackupJobRes>
// abstract deleteBackupJob(
// params: RR.DeleteBackupJobReq,
// ): Promise<RR.DeleteBackupJobRes>
// abstract getBackupRuns(
// params: RR.GetBackupRunsReq,
// ): Promise<RR.GetBackupRunsRes>
// abstract deleteBackupRuns(
// params: RR.DeleteBackupRunsReq,
// ): Promise<RR.DeleteBackupRunsRes>
// package
abstract getPackageLogs(
@@ -336,13 +355,13 @@ export abstract class ApiService {
abstract sideloadPackage(): Promise<RR.SideloadPackageRes>
abstract setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes>
// @TODO 041
abstract setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes>
// ** service outbound proxy **
// abstract setServiceOutboundProxy(
// params: RR.SetServiceOutboundProxyReq,
// ): Promise<RR.SetServiceOutboundProxyRes>
abstract initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes>

View File

@@ -9,7 +9,7 @@ import {
} from '@start9labs/shared'
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { ApiService } from './embassy-api.service'
import { BackupTargetType, RR } from './api.types'
import { RR } from './api.types'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
@@ -52,14 +52,6 @@ export class LiveApiService extends ApiService {
})
}
async uploadFile(body: Blob): Promise<string> {
return this.httpRequest({
method: Method.POST,
body,
url: `/rest/upload`,
})
}
// for getting static files: ex. instructions, licenses
async getStaticProxy(
@@ -253,7 +245,8 @@ export class LiveApiService extends ApiService {
async followServerMetrics(
params: RR.FollowServerMetricsReq,
): Promise<RR.FollowServerMetricsRes> {
return this.rpcRequest({ method: 'server.metrics', params })
// @TODO 040 implement .follow
return this.rpcRequest({ method: 'server.metrics.follow', params })
}
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
@@ -263,12 +256,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.update', params })
}
async setServerClearnetAddress(
params: RR.SetServerClearnetAddressReq,
): Promise<RR.SetServerClearnetAddressRes> {
return this.rpcRequest({ method: 'server.set-clearnet', params })
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
@@ -289,11 +276,11 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tor.reset', params })
}
async setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes> {
return this.rpcRequest({ method: 'server.proxy.set-outbound', params })
}
// async setOsOutboundProxy(
// params: RR.SetOsOutboundProxyReq,
// ): Promise<RR.SetOsOutboundProxyRes> {
// return this.rpcRequest({ method: 'server.proxy.set-outbound', params })
// }
// marketplace URLs
@@ -389,49 +376,49 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'notification.mark-unseen', params })
}
// network
// proxies
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
return this.rpcRequest({ method: 'net.proxy.add', params })
}
// async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
// return this.rpcRequest({ method: 'net.proxy.add', params })
// }
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
return this.rpcRequest({ method: 'net.proxy.update', params })
}
// async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
// return this.rpcRequest({ method: 'net.proxy.update', params })
// }
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
return this.rpcRequest({ method: 'net.proxy.delete', params })
}
// async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
// return this.rpcRequest({ method: 'net.proxy.delete', params })
// }
// domains
async claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.claim', params })
}
// async claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes> {
// return this.rpcRequest({ method: 'net.domain.me.claim', params })
// }
async deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes> {
return this.rpcRequest({ method: 'net.domain.me.delete', params })
}
// async deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes> {
// return this.rpcRequest({ method: 'net.domain.me.delete', params })
// }
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
return this.rpcRequest({ method: 'net.domain.add', params })
}
// async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
// return this.rpcRequest({ method: 'net.domain.add', params })
// }
async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
return this.rpcRequest({ method: 'net.domain.delete', params })
}
// async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
// return this.rpcRequest({ method: 'net.domain.delete', params })
// }
// port forwards
async overridePortForward(
params: RR.OverridePortReq,
): Promise<RR.OverridePortRes> {
return this.rpcRequest({ method: 'net.port-forwards.override', params })
}
// async overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes> {
// return this.rpcRequest({ method: 'net.port-forwards.override', params })
// }
// wifi
@@ -455,7 +442,7 @@ export class LiveApiService extends ApiService {
}
async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.rpcRequest({ method: 'wifi.delete', params })
return this.rpcRequest({ method: 'wifi.remove', params })
}
// smtp
@@ -483,7 +470,7 @@ export class LiveApiService extends ApiService {
}
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.rpcRequest({ method: 'ssh.delete', params })
return this.rpcRequest({ method: 'ssh.remove', params })
}
// backup
@@ -495,60 +482,22 @@ export class LiveApiService extends ApiService {
}
async addBackupTarget(
type: BackupTargetType,
params: RR.AddCifsBackupTargetReq | RR.AddCloudBackupTargetReq,
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/')
return this.rpcRequest({ method: `backup.target.${type}.add`, params })
return this.rpcRequest({ method: 'backup.target.cifs.add', params })
}
async updateBackupTarget(
type: BackupTargetType,
params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq,
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
return this.rpcRequest({ method: `backup.target.${type}.update`, params })
return this.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> {
return this.rpcRequest({ method: 'backup.target.remove', params })
}
async getBackupJobs(
params: RR.GetBackupJobsReq,
): Promise<RR.GetBackupJobsRes> {
return this.rpcRequest({ method: 'backup.job.list', params })
}
async createBackupJob(
params: RR.CreateBackupJobReq,
): Promise<RR.CreateBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.create', params })
}
async updateBackupJob(
params: RR.UpdateBackupJobReq,
): Promise<RR.UpdateBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.update', params })
}
async deleteBackupJob(
params: RR.DeleteBackupJobReq,
): Promise<RR.DeleteBackupJobRes> {
return this.rpcRequest({ method: 'backup.job.delete', params })
}
async getBackupRuns(
params: RR.GetBackupRunsReq,
): Promise<RR.GetBackupRunsRes> {
return this.rpcRequest({ method: 'backup.runs.list', params })
}
async deleteBackupRuns(
params: RR.DeleteBackupRunsReq,
): Promise<RR.DeleteBackupRunsRes> {
return this.rpcRequest({ method: 'backup.runs.delete', params })
return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo(
@@ -561,6 +510,63 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'backup.create', params })
}
// async addBackupTarget(
// type: BackupTargetType,
// params: RR.AddCifsBackupTargetReq | RR.AddCloudBackupTargetReq,
// ): Promise<RR.AddBackupTargetRes> {
// params.path = params.path.replace('/\\/g', '/')
// return this.rpcRequest({ method: `backup.target.${type}.add`, params })
// }
// async updateBackupTarget(
// type: BackupTargetType,
// params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq,
// ): Promise<RR.UpdateBackupTargetRes> {
// return this.rpcRequest({ method: `backup.target.${type}.update`, params })
// }
// async removeBackupTarget(
// params: RR.RemoveBackupTargetReq,
// ): Promise<RR.RemoveBackupTargetRes> {
// return this.rpcRequest({ method: 'backup.target.remove', params })
// }
// async getBackupJobs(
// params: RR.GetBackupJobsReq,
// ): Promise<RR.GetBackupJobsRes> {
// return this.rpcRequest({ method: 'backup.job.list', params })
// }
// async createBackupJob(
// params: RR.CreateBackupJobReq,
// ): Promise<RR.CreateBackupJobRes> {
// return this.rpcRequest({ method: 'backup.job.create', params })
// }
// async updateBackupJob(
// params: RR.UpdateBackupJobReq,
// ): Promise<RR.UpdateBackupJobRes> {
// return this.rpcRequest({ method: 'backup.job.update', params })
// }
// async deleteBackupJob(
// params: RR.DeleteBackupJobReq,
// ): Promise<RR.DeleteBackupJobRes> {
// return this.rpcRequest({ method: 'backup.job.delete', params })
// }
// async getBackupRuns(
// params: RR.GetBackupRunsReq,
// ): Promise<RR.GetBackupRunsRes> {
// return this.rpcRequest({ method: 'backup.runs.list', params })
// }
// async deleteBackupRuns(
// params: RR.DeleteBackupRunsReq,
// ): Promise<RR.DeleteBackupRunsRes> {
// return this.rpcRequest({ method: 'backup.runs.delete', params })
// }
// package
async getPackageLogs(
@@ -630,21 +636,15 @@ export class LiveApiService extends ApiService {
})
}
async setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes> {
return this.rpcRequest({ method: 'package.interface.set-clearnet', params })
}
async setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes> {
return this.rpcRequest({ method: 'package.proxy.set-outbound', params })
}
// async setServiceOutboundProxy(
// params: RR.SetServiceOutboundProxyReq,
// ): Promise<RR.SetServiceOutboundProxyRes> {
// return this.rpcRequest({ method: 'package.proxy.set-outbound', params })
// }
async removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes> {
return this.rpcRequest({
method: 'net.acme.delete',
method: 'net.acme.remove',
params,
})
}

View File

@@ -16,7 +16,7 @@ import {
StateInfo,
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { BackupTargetType, RR } from './api.types'
import { CifsBackupTarget, RR } from './api.types'
import { Mock } from './api.fixures'
import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
import { mockPatchData } from './mock-patch'
@@ -79,11 +79,6 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
}
async uploadFile(body: Blob): Promise<string> {
await pauseFor(2000)
return 'returnedhash'
}
async getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
@@ -391,23 +386,6 @@ export class MockApiService extends ApiService {
return 'updating'
}
async setServerClearnetAddress(
params: RR.SetServerClearnetAddressReq,
): Promise<RR.SetServerClearnetAddressRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/ui/domainInfo',
value: params.domainInfo,
},
]
this.mockRevision(patch)
return null
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
@@ -474,22 +452,22 @@ export class MockApiService extends ApiService {
return null
}
async setOsOutboundProxy(
params: RR.SetOsOutboundProxyReq,
): Promise<RR.SetOsOutboundProxyRes> {
await pauseFor(2000)
// async setOsOutboundProxy(
// params: RR.SetOsOutboundProxyReq,
// ): Promise<RR.SetOsOutboundProxyRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/outboundProxy',
value: params.proxy,
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/outboundProxy',
// value: params.proxy,
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
// marketplace URLs
@@ -570,151 +548,151 @@ export class MockApiService extends ApiService {
// network
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
await pauseFor(2000)
// async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.ADD,
path: `/serverInfo/network/networkInterfaces/wga1`,
value: {
inbound: true,
outbound: true,
ipInfo: {
name: params.name,
scopeId: 3,
deviceType: 'wireguard',
subnets: [],
wanIp: '1.1.1.1',
ntpServers: [],
},
},
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.ADD,
// path: `/serverInfo/network/networkInterfaces/wga1`,
// value: {
// inbound: true,
// outbound: true,
// ipInfo: {
// name: params.name,
// scopeId: 3,
// deviceType: 'wireguard',
// subnets: [],
// wanIp: '1.1.1.1',
// ntpServers: [],
// },
// },
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
await pauseFor(2000)
// async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/network/proxies/0/name`,
value: params.name,
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: `/serverInfo/network/proxies/0/name`,
// value: params.name,
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/proxies',
value: [],
},
]
this.mockRevision(patch)
// async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/proxies',
// value: [],
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
// domains
async claimStart9ToDomain(
params: RR.ClaimStart9ToReq,
): Promise<RR.ClaimStart9ToRes> {
await pauseFor(2000)
// async claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/start9To',
value: {
subdomain: 'xyz',
networkInterfaceId: params.networkInterfaceId,
},
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/start9To',
// value: {
// subdomain: 'xyz',
// networkInterfaceId: params.networkInterfaceId,
// },
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
async deleteStart9ToDomain(
params: RR.DeleteStart9ToReq,
): Promise<RR.DeleteStart9ToRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/start9To',
value: null,
},
]
this.mockRevision(patch)
// async deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/start9To',
// value: null,
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
await pauseFor(2000)
// async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/network/domains`,
value: {
[params.hostname]: {
networkInterfaceId: params.networkInterfaceId,
provider: params.provider.name,
},
},
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: `/serverInfo/network/domains`,
// value: {
// [params.hostname]: {
// networkInterfaceId: params.networkInterfaceId,
// provider: params.provider.name,
// },
// },
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/domains',
value: {},
},
]
this.mockRevision(patch)
// async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/domains',
// value: {},
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
// port forwards
async overridePortForward(
params: RR.OverridePortReq,
): Promise<RR.OverridePortRes> {
await pauseFor(2000)
// async overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes> {
// await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/wanConfig/forwards/0/override',
value: params.port,
},
]
this.mockRevision(patch)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/wanConfig/forwards/0/override',
// value: params.port,
// },
// ]
// this.mockRevision(patch)
return null
}
// return null
// }
// wifi
@@ -814,21 +792,16 @@ export class MockApiService extends ApiService {
}
async addBackupTarget(
type: BackupTargetType,
params:
| RR.AddCifsBackupTargetReq
| RR.AddCloudBackupTargetReq
| RR.AddDiskBackupTargetReq,
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
await pauseFor(2000)
const { path, name } = params
const { hostname, path, username } = params
return {
latfgvwdbhjsndmk: {
name,
type: 'cifs',
hostname: 'mockhotname',
hostname,
path: path.replace(/\\/g, '/'),
username: 'mockusername',
username,
mountable: true,
startOs: {},
},
@@ -836,11 +809,18 @@ export class MockApiService extends ApiService {
}
async updateBackupTarget(
type: BackupTargetType,
params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq,
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
await pauseFor(2000)
return { [params.id]: Mock.BackupTargets.saved[params.id] }
const { id, hostname, path, username } = params
return {
[id]: {
...(Mock.BackupTargets[id] as CifsBackupTarget),
hostname,
path,
username,
},
}
}
async removeBackupTarget(
@@ -850,57 +830,6 @@ export class MockApiService extends ApiService {
return null
}
async getBackupJobs(
params: RR.GetBackupJobsReq,
): Promise<RR.GetBackupJobsRes> {
await pauseFor(2000)
return Mock.BackupJobs
}
async createBackupJob(
params: RR.CreateBackupJobReq,
): Promise<RR.CreateBackupJobRes> {
await pauseFor(2000)
return {
id: 'hjdfbjsahdbn',
name: params.name,
targetId: Object.keys(Mock.BackupTargets.saved)[0],
cron: params.cron,
packageIds: params.packageIds,
}
}
async updateBackupJob(
params: RR.UpdateBackupJobReq,
): Promise<RR.UpdateBackupJobRes> {
await pauseFor(2000)
return {
...Mock.BackupJobs[0],
...params,
}
}
async deleteBackupJob(
params: RR.DeleteBackupJobReq,
): Promise<RR.DeleteBackupJobRes> {
await pauseFor(2000)
return null
}
async getBackupRuns(
params: RR.GetBackupRunsReq,
): Promise<RR.GetBackupRunsRes> {
await pauseFor(2000)
return Mock.BackupRuns
}
async deleteBackupRuns(
params: RR.DeleteBackupRunsReq,
): Promise<RR.DeleteBackupRunsRes> {
await pauseFor(2000)
return null
}
async getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes> {
@@ -977,6 +906,94 @@ export class MockApiService extends ApiService {
return null
}
// async addBackupTarget(
// type: BackupTargetType,
// params:
// | RR.AddCifsBackupTargetReq
// | RR.AddCloudBackupTargetReq
// | RR.AddDiskBackupTargetReq,
// ): Promise<RR.AddBackupTargetRes> {
// await pauseFor(2000)
// const { path, name } = params
// return {
// latfgvwdbhjsndmk: {
// name,
// type: 'cifs',
// hostname: 'mockhotname',
// path: path.replace(/\\/g, '/'),
// username: 'mockusername',
// mountable: true,
// startOs: {},
// },
// }
// }
// async updateBackupTarget(
// type: BackupTargetType,
// params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq,
// ): Promise<RR.UpdateBackupTargetRes> {
// await pauseFor(2000)
// return { [params.id]: Mock.BackupTargets.saved[params.id] }
// }
// async removeBackupTarget(
// params: RR.RemoveBackupTargetReq,
// ): Promise<RR.RemoveBackupTargetRes> {
// await pauseFor(2000)
// return null
// }
// async getBackupJobs(
// params: RR.GetBackupJobsReq,
// ): Promise<RR.GetBackupJobsRes> {
// await pauseFor(2000)
// return Mock.BackupJobs
// }
// async createBackupJob(
// params: RR.CreateBackupJobReq,
// ): Promise<RR.CreateBackupJobRes> {
// await pauseFor(2000)
// return {
// id: 'hjdfbjsahdbn',
// name: params.name,
// targetId: Object.keys(Mock.BackupTargets.saved)[0],
// cron: params.cron,
// packageIds: params.packageIds,
// }
// }
// async updateBackupJob(
// params: RR.UpdateBackupJobReq,
// ): Promise<RR.UpdateBackupJobRes> {
// await pauseFor(2000)
// return {
// ...Mock.BackupJobs[0],
// ...params,
// }
// }
// async deleteBackupJob(
// params: RR.DeleteBackupJobReq,
// ): Promise<RR.DeleteBackupJobRes> {
// await pauseFor(2000)
// return null
// }
// async getBackupRuns(
// params: RR.GetBackupRunsReq,
// ): Promise<RR.GetBackupRunsRes> {
// await pauseFor(2000)
// return Mock.BackupRuns
// }
// async deleteBackupRuns(
// params: RR.DeleteBackupRunsReq,
// ): Promise<RR.DeleteBackupRunsRes> {
// await pauseFor(2000)
// return null
// }
// package
async getPackageLogs(
@@ -1308,37 +1325,21 @@ export class MockApiService extends ApiService {
}
}
async setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,
): Promise<RR.SetInterfaceClearnetAddressRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/serviceInterfaces/${params.interfaceId}/addressInfo/domainInfo`,
value: params.domainInfo,
},
]
this.mockRevision(patch)
// async setServiceOutboundProxy(
// params: RR.SetServiceOutboundProxyReq,
// ): Promise<RR.SetServiceOutboundProxyRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: `/packageData/${params.packageId}/outboundProxy`,
// value: params.proxy,
// },
// ]
// this.mockRevision(patch)
return null
}
async setServiceOutboundProxy(
params: RR.SetServiceOutboundProxyReq,
): Promise<RR.SetServiceOutboundProxyRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/outboundProxy`,
value: params.proxy,
},
]
this.mockRevision(patch)
return null
}
// return null
// }
async initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes> {
await pauseFor(2000)
@@ -1820,6 +1821,16 @@ export class MockApiService extends ApiService {
},
]
this.mockRevision(patch3)
// quickly revert server to "running" for continued testing
await pauseFor(100)
const patch4 = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/status',
value: 'running',
},
]
this.mockRevision(patch4)
// set patch indicating update is complete
await pauseFor(100)
const patch6 = [

View File

@@ -39,13 +39,6 @@ export const mockPatchData: DataModel = {
selected: null,
lastRegion: null,
},
start9To: null,
domains: {},
wanConfig: {
upnp: true,
forwards: [],
},
outboundProxy: null,
host: {
bindings: {
80: {
@@ -225,9 +218,7 @@ export const mockPatchData: DataModel = {
},
dataVersion: '0.20.0:0',
icon: '/assets/img/service-icons/bitcoind.svg',
installedAt: new Date().toISOString(),
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
nextBackup: new Date(new Date().valueOf() + 100000000).toISOString(),
status: {
main: 'stopped',
},
@@ -475,18 +466,17 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
requestedActions: {
'bitcoind-config': {
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason:
'You must run Config before starting Bitcoin for the first time',
},
active: true,
},
// 'bitcoind-config': {
// request: {
// packageId: 'bitcoind',
// actionId: 'config',
// severity: 'critical',
// reason:
// 'You must run Config before starting Bitcoin for the first time',
// },
// active: true,
// },
'bitcoind-properties': {
request: {
packageId: 'bitcoind',
@@ -508,9 +498,7 @@ export const mockPatchData: DataModel = {
},
dataVersion: '0.11.0:0.0.1',
icon: '/assets/img/service-icons/lnd.png',
installedAt: new Date().toISOString(),
lastBackup: null,
nextBackup: null,
status: {
main: 'stopped',
},
@@ -604,7 +592,6 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
requestedActions: {
config: {
active: true,

View File

@@ -1,8 +1,8 @@
import { BackupJob } from '../api/api.types'
import { T } from '@start9labs/start-sdk'
export type DataModel = {
export type DataModel = Omit<T.Public, 'serverInfo'> & {
ui: UIData
// @TODO 040
serverInfo: Omit<
T.Public['serverInfo'],
'wifi' | 'networkInterfaces' | 'host'
@@ -51,54 +51,24 @@ export type NetworkInfo = {
| null
}
}
start9To: {
subdomain: string
networkInterfaceId: string
} | null
domains: {
[key: string]: Domain
}
wanConfig: {
upnp: boolean
forwards: PortForward[]
}
outboundProxy: string | null
}
export type DomainInfo = {
domain: string
subdomain: string | null
}
export type PortForward = {
assigned: number
override: number | null
target: number
error: string | null
}
export type Domain = {
provider: string
networkInterfaceId: string
}
export interface ServerStatusInfo {
currentBackup: null | {
job: BackupJob
backupProgress: Record<string, boolean>
}
updated: boolean
updateProgress: { size: number | null; downloaded: number } | null
restarting: boolean
shuttingDown: boolean
// @TODO 041
// start9To: {
// subdomain: string
// networkInterfaceId: string
// } | null
// domains: {
// [key: string]: Domain
// }
// wanConfig: {
// upnp: boolean
// forwards: PortForward[]
// }
// outboundProxy: string | null
}
export type PackageDataEntry<T extends StateInfo = StateInfo> =
T.PackageDataEntry & {
stateInfo: T
installedAt: string
outboundProxy: string | null
nextBackup: string | null
}
export type AllPackageData = NonNullable<
@@ -129,3 +99,11 @@ export type InstallingInfo = {
progress: T.FullProgress
newManifest: T.Manifest
}
// @TODO 041
// export type ServerStatusInfo = Omit<T.ServerStatus, 'backupProgress'> & {
// currentBackup: null | {
// job: BackupJob
// backupProgress: Record<string, boolean>
// }
// }

View File

@@ -1,89 +1,91 @@
import { Injectable } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { ApiService } from './api/embassy-api.service'
import { DataModel } from './patch-db/data-model'
import { ISB } from '@start9labs/start-sdk'
// @TODO 041
@Injectable({
providedIn: 'root',
})
export class ProxyService {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
private readonly api: ApiService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
) {}
// import { Injectable } from '@angular/core'
// import { ErrorService, LoadingService } from '@start9labs/shared'
// import { TuiDialogOptions } from '@taiga-ui/core'
// import { PatchDB } from 'patch-db-client'
// import { firstValueFrom } from 'rxjs'
// import {
// FormComponent,
// FormContext,
// } from 'src/app/routes/portal/components/form.component'
// import { FormDialogService } from 'src/app/services/form-dialog.service'
// import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
// import { ApiService } from './api/embassy-api.service'
// import { DataModel } from './patch-db/data-model'
// import { ISB } from '@start9labs/start-sdk'
async presentModalSetOutboundProxy(current: string | null, pkgId?: string) {
const networkInterfaces = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'networkInterfaces'),
)
const config = ISB.InputSpec.of({
proxyId: ISB.Value.select({
name: 'Select Proxy',
default: current || '',
values: Object.entries(networkInterfaces)
.filter(
([_, n]) => n.outbound && n.ipInfo?.deviceType === 'wireguard',
)
.reduce<Record<string, string>>(
(prev, [id, n]) => ({
[id]: n.ipInfo!.name,
...prev,
}),
{},
),
}),
})
// @Injectable({
// providedIn: 'root',
// })
// export class ProxyService {
// constructor(
// private readonly patch: PatchDB<DataModel>,
// private readonly formDialog: FormDialogService,
// private readonly api: ApiService,
// private readonly loader: LoadingService,
// private readonly errorService: ErrorService,
// ) {}
const options: Partial<
TuiDialogOptions<FormContext<typeof config.validator._TYPE>>
> = {
label: 'Outbound Proxy',
data: {
spec: await configBuilderToSpec(config),
buttons: [
{
text: 'Manage proxies',
link: '/portal/settings/proxies',
},
{
text: 'Save',
handler: async value => {
await this.saveOutboundProxy(value.proxyId, pkgId)
return true
},
},
],
},
}
this.formDialog.open(FormComponent, options)
}
// async presentModalSetOutboundProxy(current: string | null, pkgId?: string) {
// const networkInterfaces = await firstValueFrom(
// this.patch.watch$('serverInfo', 'network', 'networkInterfaces'),
// )
// const config = ISB.InputSpec.of({
// proxyId: ISB.Value.select({
// name: 'Select Proxy',
// default: current || '',
// values: Object.entries(networkInterfaces)
// .filter(
// ([_, n]) => n.outbound && n.ipInfo?.deviceType === 'wireguard',
// )
// .reduce<Record<string, string>>(
// (prev, [id, n]) => ({
// [id]: n.ipInfo!.name,
// ...prev,
// }),
// {},
// ),
// }),
// })
private async saveOutboundProxy(proxy: string | null, packageId?: string) {
const loader = this.loader.open(`Saving`).subscribe()
// const options: Partial<
// TuiDialogOptions<FormContext<typeof config.validator._TYPE>>
// > = {
// label: 'Outbound Proxy',
// data: {
// spec: await configBuilderToSpec(config),
// buttons: [
// {
// text: 'Manage proxies',
// link: '/portal/settings/proxies',
// },
// {
// text: 'Save',
// handler: async value => {
// await this.saveOutboundProxy(value.proxyId, pkgId)
// return true
// },
// },
// ],
// },
// }
// this.formDialog.open(FormComponent, options)
// }
try {
if (packageId) {
await this.api.setServiceOutboundProxy({ packageId, proxy })
} else {
await this.api.setOsOutboundProxy({ proxy })
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
// private async saveOutboundProxy(proxy: string | null, packageId?: string) {
// const loader = this.loader.open(`Saving`).subscribe()
// try {
// if (packageId) {
// await this.api.setServiceOutboundProxy({ packageId, proxy })
// } else {
// await this.api.setOsOutboundProxy({ proxy })
// }
// } catch (e: any) {
// this.errorService.handleError(e)
// } finally {
// loader.unsubscribe()
// }
// }
// }

View File

@@ -1,30 +0,0 @@
import {
AllPackageData,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
export function getDepDetails(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
depId: string,
) {
const { title, icon, versionRange } = pkg.currentDependencies[depId] || {}
if (
allPkgs[depId] &&
(allPkgs[depId].stateInfo.state === 'installed' ||
allPkgs[depId].stateInfo.state === 'updating')
) {
return {
title: allPkgs[depId].stateInfo.manifest!.title,
icon: allPkgs[depId].icon,
versionRange,
}
} else {
return {
title: title || depId,
icon: icon || 'assets/img/service-icons/fallback.png',
versionRange,
}
}
}

View File

@@ -20,10 +20,11 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
// icon: '@tui.globe',
// title: 'Updates',
// },
'/portal/backups': {
icon: '@tui.save',
title: 'Backups',
},
// @TODO 041
// '/portal/backups': {
// icon: '@tui.save',
// title: 'Backups',
// },
'/portal/metrics': {
icon: '@tui.activity',
title: 'Metrics',