diff --git a/web/projects/setup-wizard/src/app/components/recover.component.ts b/web/projects/setup-wizard/src/app/components/recover.component.ts index 8af676576..be7e5d6a6 100644 --- a/web/projects/setup-wizard/src/app/components/recover.component.ts +++ b/web/projects/setup-wizard/src/app/components/recover.component.ts @@ -10,7 +10,7 @@ import { RouterModule } from '@angular/router' - Use Existing Drive + Use Existing Drive Attach an existing StartOS data drive (not a backup) diff --git a/web/projects/setup-wizard/src/app/pages/home.page.ts b/web/projects/setup-wizard/src/app/pages/home.page.ts index 0a5ab3d19..a04a1cb86 100644 --- a/web/projects/setup-wizard/src/app/pages/home.page.ts +++ b/web/projects/setup-wizard/src/app/pages/home.page.ts @@ -33,7 +33,7 @@ import { StateService } from 'src/app/services/state.service' - Start Fresh + Start Fresh Get started with a brand new Start9 server diff --git a/web/projects/setup-wizard/src/app/pages/storage.page.ts b/web/projects/setup-wizard/src/app/pages/storage.page.ts index 23acdbae1..e79123ce4 100644 --- a/web/projects/setup-wizard/src/app/pages/storage.page.ts +++ b/web/projects/setup-wizard/src/app/pages/storage.page.ts @@ -34,7 +34,7 @@ import { StateService } from 'src/app/services/state.service' @for (d of drives; track d) { } diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index 45aacdfc5..7d0c25343 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -21,7 +21,7 @@ import { StateService } from 'src/app/services/state.service' @if (isKiosk) {

- + Setup Complete!

+ > } @for (address of addresses; track $index) { } @empty { - + @if (!service.static) { + + } } `, imports: [AddressItemComponent, TuiButton], diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts index 417fbe944..3edd47e39 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/clearnet.directive.ts @@ -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>> = { 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, + ), + 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 { 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) { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts index 2c8283da1..38a5a973c 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/local.directive.ts @@ -9,6 +9,7 @@ import { AddressesService } from '../interface.utils' ], }) export class LocalAddressesDirective implements AddressesService { + static = true async add() {} async remove() {} } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts index 741f09823..133bae9aa 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/directives/tor.directive.ts @@ -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>> = { + 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 { + 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() + } + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 708914c1d..b035586a5 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -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: `

Clearnet

@@ -70,9 +71,8 @@ import { AddressDetails } from './interface.utils' [addresses]="serviceInterface.addresses.local" > - 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.
>(PatchDB).watch$( - 'serverInfo', - 'network', - ) + readonly acme$ = inject>(PatchDB) + .watch$('serverInfo', 'acme') + .pipe(map(acme => Object.keys(acme))) - @Input() packageContext?: { - packageId: string - interfaceId: string - } + @Input() packageId?: string @Input({ required: true }) serviceInterface!: MappedServiceInterface } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts index 508966aff..e94e62370 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.utils.ts @@ -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 abstract remove(): Promise } @@ -20,37 +19,7 @@ export const REMOVE: Partial> = { }, } -export function getClearnetSpec({ - domains, - start9To, -}: NetworkInfo): Promise { - 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 diff --git a/web/projects/ui/src/app/routes/portal/portal.routes.ts b/web/projects/ui/src/app/routes/portal/portal.routes.ts index dfc72178c..965f3a15b 100644 --- a/web/projects/ui/src/app/routes/portal/portal.routes.ts +++ b/web/projects/ui/src/app/routes/portal/portal.routes.ts @@ -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', diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/components/targets.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/components/targets.component.ts index 822b0998f..a87efc52d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/components/targets.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/components/targets.component.ts @@ -41,7 +41,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe' {{ target.value.path }} diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/history.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/history.component.ts index 26f79a499..b85feb004 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/history.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/history.component.ts @@ -71,9 +71,9 @@ import { HasErrorPipe } from '../pipes/has-error.pipe' {{ run.job.name || 'No job' }} @if (run.report | hasError) { - + } @else { - + } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/action-request.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/action-request.component.ts index 63054cb70..baf996a15 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/action-request.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/action-request.component.ts @@ -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: ` - - {{ actionRequest.actionName }} - @if (actionRequest.dependency) { - - Service: - - {{ actionRequest.dependency.title }} - - } + - {{ actionRequest.reason || 'no reason provided' }} + {{ actionRequest.reason || 'No reason provided' }} `, + 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 - - 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, }) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/action-requests.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/action-requests.component.ts new file mode 100644 index 000000000..b93e2aa90 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/action-requests.component.ts @@ -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) { + + } + @for (request of requests().important; track $index) { + + } + @if (requests().critical.length + requests().important.length === 0) { +
No pending tasks
+ } + `, + 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() + 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 } + }) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/actions.component.ts index 28c699ce8..fc044cb01 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/actions.component.ts @@ -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)) { } - @if (canRestart) { + @if (status === 'running') { } - @if (canStart) { + @if (status === 'stopped') { @@ -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 diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/additional-item.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/additional-item.component.ts similarity index 75% rename from web/projects/ui/src/app/routes/portal/routes/services/modals/additional-item.component.ts rename to web/projects/ui/src/app/routes/portal/routes/services/components/additional-item.component.ts index 3d1992441..39480cf79 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/additional-item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/additional-item.component.ts @@ -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: ` -
+ {{ additionalItem.name }} -
{{ additionalItem.description }}
-
+ {{ additionalItem.description }} + @if (icon) { } @@ -33,7 +37,7 @@ import { }, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [TuiIcon], + imports: [TuiIcon, TuiTitle], }) export class ServiceAdditionalItemComponent { @Input({ required: true }) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/backups.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/backups.component.ts deleted file mode 100644 index ee006b1f1..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/backups.component.ts +++ /dev/null @@ -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: ` -
- Last backup - {{ previous() | i18nPlural: ago }} -
-
- Next backup - {{ next() | i18nPlural: in }} -
-
- - Manage - -
- `, - 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() - - 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)), - ) -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts index 28639a843..851799282 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts @@ -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) { - +
Dependencies
+ @for (d of pkg.currentDependencies | keyvalue; track $index) { + + + + {{ d.value.title }} + {{ d.value.versionRange }} + + + + @if (services[d.key]; as service) { + + } } @empty { - No dependencies +
No dependencies
} `, - 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 = {} } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/dependency.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/dependency.component.ts deleted file mode 100644 index fdc81066c..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/dependency.component.ts +++ /dev/null @@ -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: ` - - - - @if (dep.errorText) { - - } - {{ dep.title }} - - {{ dep.version }} - - {{ dep.errorText || 'Satisfied' }} - - - @if (dep.actionText) { - - {{ dep.actionText }} - - - } - `, - 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)' - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts index 0906fc6e3..b980ff300 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts @@ -20,17 +20,15 @@ import { getManifest } from 'src/app/utils/get-package-data' standalone: true, selector: 'service-error', template: ` +
Error
-

- - Actions - - +

+ Actions +

@@ -54,6 +52,32 @@ import { getManifest } from 'src/app/utils/get-package-data' }

`, + 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], }) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/health-checks.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/health-checks.component.ts index 2561b4241..fcfed15fb 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/health-checks.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/health-checks.component.ts @@ -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: ` +
Health Checks
@for (check of checks; track $index) { - } - - @if (!checks.length) { - No health checks + } @empty { +
No health checks
} `, - 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 { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list-item.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list-item.component.ts deleted file mode 100644 index 1ae79a08c..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list-item.component.ts +++ /dev/null @@ -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: ` - - @if (check === null) { - - } @else if (check === '') { - - } @else { - - } - - {{ info.name }} - {{ info.description }} - @if (check) { - - - Health check failed: - {{ check }} - - - } @else { - - {{ info.typeDetail }} - - } - - @if (info.type === 'ui') { - - } - - `, - 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) - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list.component.ts deleted file mode 100644 index 147ad928c..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-list.component.ts +++ /dev/null @@ -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) { - - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [RouterLink, InterfaceInfoPipe, ServiceInterfaceListItemComponent], -}) -export class ServiceInterfaceListComponent { - @Input({ required: true }) - pkg!: PackageDataEntry - - @Input({ required: true }) - status!: PackageStatus -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interface.component.ts new file mode 100644 index 000000000..6b492d1ec --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/interface.component.ts @@ -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: ` + + + {{ info.name }} + + + + {{ info.type }} + + {{ info.description }} + + @if (info.public) { + + } @else { + + } + + + @if (info.type === 'ui') { + + Open + + } + + `, + 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() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts new file mode 100644 index 000000000..7906ce681 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts @@ -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: ` +
Interfaces
+ + + + + + + + + + + @for (info of interfaces(); track $index) { + + } +
NameTypeDescriptionHosting
+ `, + 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() + 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}`, + } + }), + ) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/menu-item.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/menu-item.component.ts deleted file mode 100644 index 1cc92bd86..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/menu-item.component.ts +++ /dev/null @@ -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: ` - - - {{ menu.name }} - - {{ menu.description }} - - - - - `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [TuiIcon, TuiTitle], - hostDirectives: [TuiCell], -}) -export class ServiceMenuItemComponent { - @Input({ required: true, alias: 'serviceMenuItem' }) - menu!: ServiceMenu -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/menu.component.ts deleted file mode 100644 index be9f3fa23..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/menu.component.ts +++ /dev/null @@ -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) { - - } @else { - - } - } - `, - 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)' - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/status.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/status.component.ts index 196115c91..0276640f0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/status.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/status.component.ts @@ -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) { - - - Installing - - {{ installingInfo.progress.overall | installingProgressString }} - - } @else { - - {{ connected ? rendering.display : 'Unknown' }} - @if (rendering.showDots) { - +
Status
+
+ @if (installingInfo) { + + + Installing + + {{ installingInfo.progress.overall | installingProgressString }} + + } @else { + + {{ connected ? rendering.display : 'Unknown' }} + @if (rendering.showDots) { + + } } - } + +
`, 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' diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts index 546f7bc62..dc55d5a77 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts @@ -12,7 +12,7 @@ import { ServicesService } from './services.service' @Component({ standalone: true, template: ` - +
@@ -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$) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts index ec01e98c9..30f37119b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts @@ -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; diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts index b96a4ce45..7abbcaf34 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts @@ -18,7 +18,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe' } @else { @if (healthy) { - + } @else { } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts index 71e4d7db6..82c6eb68d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts @@ -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, diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/additional.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/modals/additional.component.ts deleted file mode 100644 index d8d6bb86f..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/additional.component.ts +++ /dev/null @@ -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')) { - - } @else { - - } - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ToAdditionalPipe, ServiceAdditionalItemComponent], -}) -export class ServiceAdditionalModal { - readonly pkg = injectContext>().data -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts index 8d2aa048e..7e387ba4f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts @@ -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', diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/interface-info.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/interface-info.pipe.ts deleted file mode 100644 index 973f9f675..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/interface-info.pipe.ts +++ /dev/null @@ -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}`, - } - }) - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-action-requests.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-action-requests.pipe.ts deleted file mode 100644 index c669fc54f..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-action-requests.pipe.ts +++ /dev/null @@ -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) { - 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 } - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-additional.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-additional.pipe.ts deleted file mode 100644 index 820d44532..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-additional.pipe.ts +++ /dev/null @@ -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() - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-menu.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-menu.pipe.ts deleted file mode 100644 index 6dd9245b7..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/to-menu.pipe.ts +++ /dev/null @@ -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(['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() - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts new file mode 100644 index 000000000..62ae91a09 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/about.component.ts @@ -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: ` +
+ @for (additional of items(); track $index) { + @if (additional.description.startsWith('http')) { + + } @else { + + } + } +
+ `, + 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) + .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, + }, + ] + }), + ), + ) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts index f8ed6c378..68500ab1a 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts @@ -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) { -
-

Standard Actions

+
+
Standard Actions
- @if (pkg.actions.length) { -

Actions for {{ pkg.manifest.title }}

- } - @for (action of pkg.actions; track $index) { - @if (action.visibility !== 'hidden') { - + @for (group of pkg.actions | keyvalue; track $index) { + @if (group.value.length) { +
+
{{ group.key }}
+ @for (a of group.value; track $index) { + @if (a.visibility !== 'hidden') { + + } + } +
} } } `, + 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> + >( + (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 }, }) } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index 99643f5ca..10639a78f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -13,15 +13,16 @@ import { ConfigService } from 'src/app/services/config.service' template: ` `, + host: { class: 'g-subpage' }, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [CommonModule, InterfaceComponent], }) -export class ServiceInterfaceRoute { +export default class ServiceInterfaceRoute { private readonly patch = inject>(PatchDB) private readonly config = inject(ConfigService) diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/logs.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/logs.component.ts index 90ff05069..2b350c92f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/logs.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/logs.component.ts @@ -8,10 +8,10 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' template: '', 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() diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/markdown.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/markdown.component.ts new file mode 100644 index 000000000..7bb0cc420 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/markdown.component.ts @@ -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()) { + + {{ error() }} + + } + + @if (content(); as result) { +
+ } @else { + + } + `, + 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(this.data['content']) + readonly error = toSignal( + this.data['content'].pipe( + ignoreElements(), + catchError(e => of(getErrorMessage(e))), + ), + ) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts index 9bc8c1c54..f67ae399f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts @@ -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: ` - + @if (service()) { + + } `, 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) - 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), ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts index 092dab46f..838461800 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts @@ -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) { -
-

Status

- + + @if (installed() && connected()) { + + } + - @if (isInstalled(service) && (connected$ | async)) { - - } -
- - @if (isInstalled(service)) { -
-

Backups

- -
- - @if (service.pkg.status.main === 'error') { -
-

Error

- -
- } @else { -
-

Metrics

- TODO -
- } - -
-

Menu

- -
- -
- @if (service.pkg | toActionRequests: service.allPkgs; as requests) { - @if (requests.critical.length) { -
-

Required Actions

- @for (request of requests.critical; track $index) { - - } -
- } - - @if (requests.important.length) { -
-

Requested Actions

- @for (request of requests.important; track $index) { - - } -
- } - } - -
-

Health Checks

- -
- -
-

Dependencies

- -
-
- -
-

Service Interfaces

- -
+ @if (installed()) { + @if (pkg().status.main === 'error') { + } - @if (isInstalling(service.pkg.stateInfo.state)) { - @for ( - item of service.pkg.stateInfo.installingInfo?.progress?.phases; - track $index - ) { -

{{ item.name }}

- } + + + + +
+
Tasks
+ +
+ } + + @if (installing()) { + @for ( + item of pkg().stateInfo.installingInfo?.progress?.phases; + track $index + ) { +

{{ item.name }}

} } `, 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) - 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).watch$('packageData'), + { initialValue: {} as Record }, ) - 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 { - switch (action) { - case 'install': - case 'update': - return this.installDep(pkg, pkgManifest, depId) - case 'configure': - // return this.formDialog.open(ConfigModal, { - // label: `${pkg.currentDependencies[depId].title} config`, - // data: { - // pkgId: depId, - // dependentInfo: pkgManifest, - // }, - // }) - } - } - - private async installDep( - pkg: PackageDataEntry, - manifest: T.Manifest, - depId: string, - ): Promise { - 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) } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/services.module.ts b/web/projects/ui/src/app/routes/portal/routes/services/services.module.ts deleted file mode 100644 index b795f237a..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/services/services.module.ts +++ /dev/null @@ -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 {} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/services.routes.ts b/web/projects/ui/src/app/routes/portal/routes/services/services.routes.ts new file mode 100644 index 000000000..f51f6ac68 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/services.routes.ts @@ -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> { + return ({ paramMap }: ActivatedRouteSnapshot) => + of(inject(ApiService)).pipe( + map(api => + defer(() => api.getStaticInstalled(paramMap.get('pkgId')!, path)).pipe( + share(), + ), + ), + ) +} + +export default ROUTES diff --git a/web/projects/ui/src/app/routes/portal/routes/services/types/mapped-interface.ts b/web/projects/ui/src/app/routes/portal/routes/services/types/mapped-interface.ts new file mode 100644 index 000000000..e0aefb2a0 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/types/mapped-interface.ts @@ -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 +} diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts index 4e8233481..1d9d2e75d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/settings/components/menu.component.ts @@ -22,6 +22,7 @@ import { SettingsUpdateComponent } from './update.component' /> +
diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/routes/interfaces/ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/settings/routes/interfaces/ui.component.ts index f7bf0fd3a..3f7f91664 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/routes/interfaces/ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/settings/routes/interfaces/ui.component.ts @@ -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', diff --git a/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts b/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts index 7b4a3c030..628f0f643 100644 --- a/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/settings/settings.service.ts @@ -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) diff --git a/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts b/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts index f0baf1afc..c01529ec0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts @@ -45,7 +45,7 @@ import { parseS9pk } from './sideload.utils' @if (error()) {
-

{{ error() }}

+

{{ error() }}

} @else { @@ -53,7 +53,7 @@ import { parseS9pk } from './sideload.utils'

Upload .s9pk package file

@if (isTor) { -

Tip: switch to LAN for faster uploads

+

Tip: switch to LAN for faster uploads

} diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts index 3bf4b9e11..c14510370 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts @@ -55,7 +55,7 @@ // // @if (localPkg.stateInfo.state === 'updating') { // Request Failed

+//

Request Failed

// } // @if (data.mp[host.url]?.packages | filterUpdates: data.local; as pkgs) { // @for (pkg of pkgs; track pkg) { diff --git a/web/projects/ui/src/app/routing.module.ts b/web/projects/ui/src/app/routing.module.ts index 4432ac14d..ce066b979 100644 --- a/web/projects/ui/src/app/routing.module.ts +++ b/web/projects/ui/src/app/routing.module.ts @@ -37,6 +37,7 @@ const routes: Routes = [ imports: [ RouterModule.forRoot(routes, { scrollPositionRestoration: 'enabled', + paramsInheritanceStrategy: 'always', preloadingStrategy: PreloadAllModules, initialNavigation: 'disabled', }), diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 35d1f8359..75022a0e5 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -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, diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 0760d45ed..ad36ecddc 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -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 = Omit, '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[] + // @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 - } + 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 + 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 & { - 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 } -export interface BaseBackupTarget { - type: BackupTargetType - name: string - mountable: boolean +export interface CifsBackupTarget { + type: 'cifs' + hostname: string path: string + username: string + mountable: boolean startOs: Record } -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 = { packageId: string | null createdAt: string code: T - level: 'success' | 'info' | 'warning' | 'error' + level: NotificationLevel title: string message: string data: NotificationData + // @TODO 040 read: boolean } +export type NotificationLevel = 'success' | 'info' | 'warning' | 'error' + export type NotificationData = 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 +// } + +// 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 + +// 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 & { +// 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 +// } + +// export interface BaseBackupTarget { +// type: BackupTargetType +// name: string +// mountable: boolean +// path: string +// startOs: Record +// } + +// 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' diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 348d4ef71..7cced9105 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -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 - abstract uploadFile(body: Blob): Promise - // 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 - abstract setServerClearnetAddress( - params: RR.SetServerClearnetAddressReq, - ): Promise - abstract restartServer( params: RR.RestartServerReq, ): Promise @@ -134,9 +127,13 @@ export abstract class ApiService { abstract resetTor(params: RR.ResetTorReq): Promise - abstract setOsOutboundProxy( - params: RR.SetOsOutboundProxyReq, - ): Promise + // @TODO 041 + + // ** server outbound proxy ** + + // abstract setOsOutboundProxy( + // params: RR.SetOsOutboundProxyReq, + // ): Promise // smtp @@ -187,33 +184,39 @@ export abstract class ApiService { params: RR.DeleteNotificationReq, ): Promise - // network + // ** proxies ** - abstract addProxy(params: RR.AddProxyReq): Promise + // @TODO 041 - abstract updateProxy(params: RR.UpdateProxyReq): Promise + // abstract addProxy(params: RR.AddProxyReq): Promise - abstract deleteProxy(params: RR.DeleteProxyReq): Promise + // abstract updateProxy(params: RR.UpdateProxyReq): Promise - // domains + // abstract deleteProxy(params: RR.DeleteProxyReq): Promise - abstract claimStart9ToDomain( - params: RR.ClaimStart9ToReq, - ): Promise + // ** domains ** - abstract deleteStart9ToDomain( - params: RR.DeleteStart9ToReq, - ): Promise + // @TODO 041 - abstract addDomain(params: RR.AddDomainReq): Promise + // abstract claimStart9ToDomain( + // params: RR.ClaimStart9ToReq, + // ): Promise - abstract deleteDomain(params: RR.DeleteDomainReq): Promise + // abstract deleteStart9ToDomain( + // params: RR.DeleteStart9ToReq, + // ): Promise - // port forwards + // abstract addDomain(params: RR.AddDomainReq): Promise - abstract overridePortForward( - params: RR.OverridePortReq, - ): Promise + // abstract deleteDomain(params: RR.DeleteDomainReq): Promise + + // ** port forwards ** + + // @TODO 041 + + // abstract overridePortForward( + // params: RR.OverridePortReq, + // ): Promise // wifi @@ -245,55 +248,71 @@ export abstract class ApiService { ): Promise abstract addBackupTarget( - type: BackupTargetType, - params: - | RR.AddCifsBackupTargetReq - | RR.AddCloudBackupTargetReq - | RR.AddDiskBackupTargetReq, + params: RR.AddBackupTargetReq, ): Promise abstract updateBackupTarget( - type: BackupTargetType, - params: - | RR.UpdateCifsBackupTargetReq - | RR.UpdateCloudBackupTargetReq - | RR.UpdateDiskBackupTargetReq, + params: RR.UpdateBackupTargetReq, ): Promise abstract removeBackupTarget( params: RR.RemoveBackupTargetReq, ): Promise - abstract getBackupJobs( - params: RR.GetBackupJobsReq, - ): Promise - - abstract createBackupJob( - params: RR.CreateBackupJobReq, - ): Promise - - abstract updateBackupJob( - params: RR.UpdateBackupJobReq, - ): Promise - - abstract deleteBackupJob( - params: RR.DeleteBackupJobReq, - ): Promise - - abstract getBackupRuns( - params: RR.GetBackupRunsReq, - ): Promise - - abstract deleteBackupRuns( - params: RR.DeleteBackupRunsReq, - ): Promise - abstract getBackupInfo( params: RR.GetBackupInfoReq, ): Promise abstract createBackup(params: RR.CreateBackupReq): Promise + // @TODO 041 + + // ** automated backups ** + + // abstract addBackupTarget( + // type: BackupTargetType, + // params: + // | RR.AddCifsBackupTargetReq + // | RR.AddCloudBackupTargetReq + // | RR.AddDiskBackupTargetReq, + // ): Promise + + // abstract updateBackupTarget( + // type: BackupTargetType, + // params: + // | RR.UpdateCifsBackupTargetReq + // | RR.UpdateCloudBackupTargetReq + // | RR.UpdateDiskBackupTargetReq, + // ): Promise + + // abstract removeBackupTarget( + // params: RR.RemoveBackupTargetReq, + // ): Promise + + // abstract getBackupJobs( + // params: RR.GetBackupJobsReq, + // ): Promise + + // abstract createBackupJob( + // params: RR.CreateBackupJobReq, + // ): Promise + + // abstract updateBackupJob( + // params: RR.UpdateBackupJobReq, + // ): Promise + + // abstract deleteBackupJob( + // params: RR.DeleteBackupJobReq, + // ): Promise + + // abstract getBackupRuns( + // params: RR.GetBackupRunsReq, + // ): Promise + + // abstract deleteBackupRuns( + // params: RR.DeleteBackupRunsReq, + // ): Promise + // package abstract getPackageLogs( @@ -336,13 +355,13 @@ export abstract class ApiService { abstract sideloadPackage(): Promise - abstract setInterfaceClearnetAddress( - params: RR.SetInterfaceClearnetAddressReq, - ): Promise + // @TODO 041 - abstract setServiceOutboundProxy( - params: RR.SetServiceOutboundProxyReq, - ): Promise + // ** service outbound proxy ** + + // abstract setServiceOutboundProxy( + // params: RR.SetServiceOutboundProxyReq, + // ): Promise abstract initAcme(params: RR.InitAcmeReq): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 3aa536684..4530c5a1f 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -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 { - 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 { - return this.rpcRequest({ method: 'server.metrics', params }) + // @TODO 040 implement .follow + return this.rpcRequest({ method: 'server.metrics.follow', params }) } async updateServer(url?: string): Promise { @@ -263,12 +256,6 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'server.update', params }) } - async setServerClearnetAddress( - params: RR.SetServerClearnetAddressReq, - ): Promise { - return this.rpcRequest({ method: 'server.set-clearnet', params }) - } - async restartServer( params: RR.RestartServerReq, ): Promise { @@ -289,11 +276,11 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'net.tor.reset', params }) } - async setOsOutboundProxy( - params: RR.SetOsOutboundProxyReq, - ): Promise { - return this.rpcRequest({ method: 'server.proxy.set-outbound', params }) - } + // async setOsOutboundProxy( + // params: RR.SetOsOutboundProxyReq, + // ): Promise { + // 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 { - return this.rpcRequest({ method: 'net.proxy.add', params }) - } + // async addProxy(params: RR.AddProxyReq): Promise { + // return this.rpcRequest({ method: 'net.proxy.add', params }) + // } - async updateProxy(params: RR.UpdateProxyReq): Promise { - return this.rpcRequest({ method: 'net.proxy.update', params }) - } + // async updateProxy(params: RR.UpdateProxyReq): Promise { + // return this.rpcRequest({ method: 'net.proxy.update', params }) + // } - async deleteProxy(params: RR.DeleteProxyReq): Promise { - return this.rpcRequest({ method: 'net.proxy.delete', params }) - } + // async deleteProxy(params: RR.DeleteProxyReq): Promise { + // return this.rpcRequest({ method: 'net.proxy.delete', params }) + // } // domains - async claimStart9ToDomain( - params: RR.ClaimStart9ToReq, - ): Promise { - return this.rpcRequest({ method: 'net.domain.me.claim', params }) - } + // async claimStart9ToDomain( + // params: RR.ClaimStart9ToReq, + // ): Promise { + // return this.rpcRequest({ method: 'net.domain.me.claim', params }) + // } - async deleteStart9ToDomain( - params: RR.DeleteStart9ToReq, - ): Promise { - return this.rpcRequest({ method: 'net.domain.me.delete', params }) - } + // async deleteStart9ToDomain( + // params: RR.DeleteStart9ToReq, + // ): Promise { + // return this.rpcRequest({ method: 'net.domain.me.delete', params }) + // } - async addDomain(params: RR.AddDomainReq): Promise { - return this.rpcRequest({ method: 'net.domain.add', params }) - } + // async addDomain(params: RR.AddDomainReq): Promise { + // return this.rpcRequest({ method: 'net.domain.add', params }) + // } - async deleteDomain(params: RR.DeleteDomainReq): Promise { - return this.rpcRequest({ method: 'net.domain.delete', params }) - } + // async deleteDomain(params: RR.DeleteDomainReq): Promise { + // return this.rpcRequest({ method: 'net.domain.delete', params }) + // } // port forwards - async overridePortForward( - params: RR.OverridePortReq, - ): Promise { - return this.rpcRequest({ method: 'net.port-forwards.override', params }) - } + // async overridePortForward( + // params: RR.OverridePortReq, + // ): Promise { + // 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 { - 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 { - 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 { 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 { - return this.rpcRequest({ method: `backup.target.${type}.update`, params }) + return this.rpcRequest({ method: 'backup.target.cifs.update', params }) } async removeBackupTarget( params: RR.RemoveBackupTargetReq, ): Promise { - return this.rpcRequest({ method: 'backup.target.remove', params }) - } - - async getBackupJobs( - params: RR.GetBackupJobsReq, - ): Promise { - return this.rpcRequest({ method: 'backup.job.list', params }) - } - - async createBackupJob( - params: RR.CreateBackupJobReq, - ): Promise { - return this.rpcRequest({ method: 'backup.job.create', params }) - } - - async updateBackupJob( - params: RR.UpdateBackupJobReq, - ): Promise { - return this.rpcRequest({ method: 'backup.job.update', params }) - } - - async deleteBackupJob( - params: RR.DeleteBackupJobReq, - ): Promise { - return this.rpcRequest({ method: 'backup.job.delete', params }) - } - - async getBackupRuns( - params: RR.GetBackupRunsReq, - ): Promise { - return this.rpcRequest({ method: 'backup.runs.list', params }) - } - - async deleteBackupRuns( - params: RR.DeleteBackupRunsReq, - ): Promise { - 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 { + // 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 { + // return this.rpcRequest({ method: `backup.target.${type}.update`, params }) + // } + + // async removeBackupTarget( + // params: RR.RemoveBackupTargetReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.target.remove', params }) + // } + + // async getBackupJobs( + // params: RR.GetBackupJobsReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.job.list', params }) + // } + + // async createBackupJob( + // params: RR.CreateBackupJobReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.job.create', params }) + // } + + // async updateBackupJob( + // params: RR.UpdateBackupJobReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.job.update', params }) + // } + + // async deleteBackupJob( + // params: RR.DeleteBackupJobReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.job.delete', params }) + // } + + // async getBackupRuns( + // params: RR.GetBackupRunsReq, + // ): Promise { + // return this.rpcRequest({ method: 'backup.runs.list', params }) + // } + + // async deleteBackupRuns( + // params: RR.DeleteBackupRunsReq, + // ): Promise { + // 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 { - return this.rpcRequest({ method: 'package.interface.set-clearnet', params }) - } - - async setServiceOutboundProxy( - params: RR.SetServiceOutboundProxyReq, - ): Promise { - return this.rpcRequest({ method: 'package.proxy.set-outbound', params }) - } + // async setServiceOutboundProxy( + // params: RR.SetServiceOutboundProxyReq, + // ): Promise { + // return this.rpcRequest({ method: 'package.proxy.set-outbound', params }) + // } async removeAcme(params: RR.RemoveAcmeReq): Promise { return this.rpcRequest({ - method: 'net.acme.delete', + method: 'net.acme.remove', params, }) } diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 17a29eaed..4496661f0 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -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 { - 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 { - 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 { @@ -474,22 +452,22 @@ export class MockApiService extends ApiService { return null } - async setOsOutboundProxy( - params: RR.SetOsOutboundProxyReq, - ): Promise { - await pauseFor(2000) + // async setOsOutboundProxy( + // params: RR.SetOsOutboundProxyReq, + // ): Promise { + // 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 { - await pauseFor(2000) + // async addProxy(params: RR.AddProxyReq): Promise { + // 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 { - await pauseFor(2000) + // async updateProxy(params: RR.UpdateProxyReq): Promise { + // 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 { - await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: '/serverInfo/network/proxies', - value: [], - }, - ] - this.mockRevision(patch) + // async deleteProxy(params: RR.DeleteProxyReq): Promise { + // 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 { - await pauseFor(2000) + // async claimStart9ToDomain( + // params: RR.ClaimStart9ToReq, + // ): Promise { + // 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 { - await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: '/serverInfo/network/start9To', - value: null, - }, - ] - this.mockRevision(patch) + // async deleteStart9ToDomain( + // params: RR.DeleteStart9ToReq, + // ): Promise { + // 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 { - await pauseFor(2000) + // async addDomain(params: RR.AddDomainReq): Promise { + // 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 { - await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: '/serverInfo/network/domains', - value: {}, - }, - ] - this.mockRevision(patch) + // async deleteDomain(params: RR.DeleteDomainReq): Promise { + // 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 { - await pauseFor(2000) + // async overridePortForward( + // params: RR.OverridePortReq, + // ): Promise { + // 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 { 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 { 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 { - await pauseFor(2000) - return Mock.BackupJobs - } - - async createBackupJob( - params: RR.CreateBackupJobReq, - ): Promise { - 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 { - await pauseFor(2000) - return { - ...Mock.BackupJobs[0], - ...params, - } - } - - async deleteBackupJob( - params: RR.DeleteBackupJobReq, - ): Promise { - await pauseFor(2000) - return null - } - - async getBackupRuns( - params: RR.GetBackupRunsReq, - ): Promise { - await pauseFor(2000) - return Mock.BackupRuns - } - - async deleteBackupRuns( - params: RR.DeleteBackupRunsReq, - ): Promise { - await pauseFor(2000) - return null - } - async getBackupInfo( params: RR.GetBackupInfoReq, ): Promise { @@ -977,6 +906,94 @@ export class MockApiService extends ApiService { return null } + // async addBackupTarget( + // type: BackupTargetType, + // params: + // | RR.AddCifsBackupTargetReq + // | RR.AddCloudBackupTargetReq + // | RR.AddDiskBackupTargetReq, + // ): Promise { + // 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 { + // await pauseFor(2000) + // return { [params.id]: Mock.BackupTargets.saved[params.id] } + // } + + // async removeBackupTarget( + // params: RR.RemoveBackupTargetReq, + // ): Promise { + // await pauseFor(2000) + // return null + // } + + // async getBackupJobs( + // params: RR.GetBackupJobsReq, + // ): Promise { + // await pauseFor(2000) + // return Mock.BackupJobs + // } + + // async createBackupJob( + // params: RR.CreateBackupJobReq, + // ): Promise { + // 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 { + // await pauseFor(2000) + // return { + // ...Mock.BackupJobs[0], + // ...params, + // } + // } + + // async deleteBackupJob( + // params: RR.DeleteBackupJobReq, + // ): Promise { + // await pauseFor(2000) + // return null + // } + + // async getBackupRuns( + // params: RR.GetBackupRunsReq, + // ): Promise { + // await pauseFor(2000) + // return Mock.BackupRuns + // } + + // async deleteBackupRuns( + // params: RR.DeleteBackupRunsReq, + // ): Promise { + // await pauseFor(2000) + // return null + // } + // package async getPackageLogs( @@ -1308,37 +1325,21 @@ export class MockApiService extends ApiService { } } - async setInterfaceClearnetAddress( - params: RR.SetInterfaceClearnetAddressReq, - ): Promise { - 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 { + // 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 { - 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 { 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 = [ diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index a26ce0759..91077c145 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -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, diff --git a/web/projects/ui/src/app/services/patch-db/data-model.ts b/web/projects/ui/src/app/services/patch-db/data-model.ts index e7dd72fa8..b150dd4a8 100644 --- a/web/projects/ui/src/app/services/patch-db/data-model.ts +++ b/web/projects/ui/src/app/services/patch-db/data-model.ts @@ -1,8 +1,8 @@ -import { BackupJob } from '../api/api.types' import { T } from '@start9labs/start-sdk' -export type DataModel = { +export type DataModel = Omit & { 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 - } - 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.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 & { +// currentBackup: null | { +// job: BackupJob +// backupProgress: Record +// } +// } diff --git a/web/projects/ui/src/app/services/proxy.service.ts b/web/projects/ui/src/app/services/proxy.service.ts index 72ac7c9d9..75ab54a99 100644 --- a/web/projects/ui/src/app/services/proxy.service.ts +++ b/web/projects/ui/src/app/services/proxy.service.ts @@ -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, - 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>( - (prev, [id, n]) => ({ - [id]: n.ipInfo!.name, - ...prev, - }), - {}, - ), - }), - }) +// @Injectable({ +// providedIn: 'root', +// }) +// export class ProxyService { +// constructor( +// private readonly patch: PatchDB, +// private readonly formDialog: FormDialogService, +// private readonly api: ApiService, +// private readonly loader: LoadingService, +// private readonly errorService: ErrorService, +// ) {} - const options: Partial< - TuiDialogOptions> - > = { - 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>( +// (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> +// > = { +// 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() +// } +// } +// } diff --git a/web/projects/ui/src/app/utils/dep-info.ts b/web/projects/ui/src/app/utils/dep-info.ts deleted file mode 100644 index ea0866d41..000000000 --- a/web/projects/ui/src/app/utils/dep-info.ts +++ /dev/null @@ -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, - } - } -} diff --git a/web/projects/ui/src/app/utils/system-utilities.ts b/web/projects/ui/src/app/utils/system-utilities.ts index 805b743a6..68990d23e 100644 --- a/web/projects/ui/src/app/utils/system-utilities.ts +++ b/web/projects/ui/src/app/utils/system-utilities.ts @@ -20,10 +20,11 @@ export const SYSTEM_UTILITIES: Record = // 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', diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index 07ff8504d..15f841bbb 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -65,8 +65,76 @@ hr { } } -.g-table { - width: 100%; +.g-subpage { + height: 100%; + min-height: fit-content; + flex: 1; + padding: 2rem; + + tui-root._mobile & { + padding: 1rem; + } +} + +.g-card { + transition: all 300ms ease-in-out; + padding: 1.25rem 1.5rem; + border-radius: 0.5rem; + overflow: hidden; + background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent); + background-image: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.15), + transparent + ), + linear-gradient(to bottom, rgba(255, 255, 255, 0.15), transparent); + background-size: 1px 100%; + background-repeat: no-repeat; + background-position: + top left, + top right; + box-sizing: border-box; + box-shadow: + 0 0.25rem 0.125rem rgba(0, 0, 0, 0.25), + 0 -0.125rem 0.25rem rgba(55, 155, 255, 0.08), + 0 0 0.5rem rgba(0, 0, 0, 0.3), + inset 0 -0.125rem rgba(255, 255, 255, 0.03), + inset 0 2px rgba(255, 255, 255, 0.1), + inset 0 1px rgba(255, 255, 255, 0.15), + inset 0 0 1rem rgba(0, 0, 0, 0.25); + + &:hover { + box-shadow: + 0 0.375rem 0.5rem rgba(0, 0, 0, 0.25), + 0 -0.125rem 0.25rem rgba(55, 155, 255, 0.08), + 0 0 0.5rem rgba(0, 0, 0, 0.3), + inset 0 -0.125rem rgba(255, 255, 255, 0.03), + inset 0 2px rgba(255, 255, 255, 0.1), + inset 0 1px rgba(255, 255, 255, 0.15), + inset 0 0 1rem rgba(0, 0, 0, 0.25); + } + + > [tuiCell]:not(:last-child)::after { + content: ''; + position: absolute; + top: 100%; + left: 1rem; + right: 1rem; + height: 1px; + background: var(--tui-border-normal); + } + + > header { + padding-bottom: 0.75rem; + margin: -0.5rem 0 0.5rem; + background: var(--tui-background-neutral-1); + box-shadow: 0 -10rem 0 10rem var(--tui-background-neutral-1); + font: var(--tui-font-heading-6); + } +} + +.g-table:not([tuiTable]) { + width: stretch; min-width: 40rem; border-spacing: 0; @@ -127,6 +195,38 @@ hr { } } +.g-table[tuiTable] { + width: stretch; + + 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 { + padding: 0.5rem; + } + + td:only-child { + text-align: center; + padding: 1rem; + } + + tui-root._mobile & thead { + display: none; + } +} + .g-title { display: flex; align-items: center; @@ -196,7 +296,7 @@ button.g-action { } } -.g-success { +.g-positive { color: var(--tui-status-positive) !important; } @@ -204,7 +304,7 @@ button.g-action { color: var(--tui-status-warning) !important; } -.g-error.g-error { +.g-negative.g-negative { color: var(--tui-status-negative) !important; } @@ -212,6 +312,10 @@ button.g-action { color: var(--tui-status-info) !important; } +.g-secondary { + color: var(--tui-text-secondary) !important; +} + ng-component { display: block; }