fix a few, more to go (#2869)

* fix a few, more to go

* chore: comments (#2871)

* chore: comments

* chore: typo

* chore: stricter typescript (#2872)

* chore: comments

* chore: stricter typescript

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>

* minor styling

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2025-04-12 09:53:03 -06:00
committed by GitHub
parent 6a312e3fdd
commit 2e6e9635c3
55 changed files with 440 additions and 343 deletions

View File

@@ -54,7 +54,6 @@ header {
} }
store-icon { store-icon {
margin-bottom: 0.75rem;
border-radius: 100%; border-radius: 100%;
height: 64px; height: 64px;
} }

View File

@@ -48,6 +48,6 @@ export class LogsWindowComponent {
} }
onBottom(entries: readonly IntersectionObserverEntry[]) { onBottom(entries: readonly IntersectionObserverEntry[]) {
this.scroll = entries[entries.length - 1].isIntersecting this.scroll = !!entries[entries.length - 1]?.isIntersecting
} }
} }

View File

@@ -25,9 +25,12 @@ export function convertBytes(bytes: number): string {
export class DurationToSecondsPipe implements PipeTransform { export class DurationToSecondsPipe implements PipeTransform {
transform(duration?: string | null): number { transform(duration?: string | null): number {
if (!duration) return 0 if (!duration) return 0
const [, num, , unit] =
duration.match(/^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/) || [] const regex = /^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/
return Number(num) * unitsToSeconds[unit] const [, num, , unit] = duration.match(regex) || []
const multiplier = (unit && unitsToSeconds[unit]) || NaN
return unit ? Number(num) * multiplier : NaN
} }
} }

View File

@@ -294,13 +294,3 @@ a {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
h1,
h2,
h3,
h4,
h5,
h6,
hr {
margin: 0;
}

View File

@@ -42,7 +42,7 @@ export class AppComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.patch.watch$('ui').subscribe(({ name, language }) => { this.patch.watch$('ui').subscribe(({ name, language }) => {
this.title.setTitle(name || 'StartOS') this.title.setTitle(name || 'StartOS')
this.i18n.setLanguage(language) this.i18n.setLanguage(language || 'english')
}) })
} }
} }

View File

@@ -114,6 +114,6 @@ export function appInitializer(): () => void {
auth.init() auth.init()
localStorage.init() localStorage.init()
router.initialNavigation() router.initialNavigation()
i18n.setLanguage(i18n.language) i18n.setLanguage(i18n.language || 'english')
} }
} }

View File

@@ -13,7 +13,7 @@ export class i18nService extends TuiLanguageSwitcherService {
readonly loading = signal(false) readonly loading = signal(false)
override setLanguage(language: TuiLanguageName): void { override setLanguage(language: TuiLanguageName = 'english'): void {
if (this.language === language) { if (this.language === language) {
return return
} }

View File

@@ -11,7 +11,7 @@
<section <section
class="top" class="top"
waIntersectionObserver waIntersectionObserver
(waIntersectionObservee)="onTop($event[0].isIntersecting)" (waIntersectionObservee)="onTop(!!$event[0]?.isIntersecting)"
> >
@if (loading) { @if (loading) {
<tui-loader textContent="Loading logs" /> <tui-loader textContent="Loading logs" />

View File

@@ -16,7 +16,8 @@ export class FormMultiselectComponent extends Control<
private readonly isDisabled = (item: string) => private readonly isDisabled = (item: string) =>
Array.isArray(this.spec.disabled) && Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item]) !!this.inverted[item] &&
this.spec.disabled.includes(this.inverted[item]!)
private readonly isExceedingLimit = (item: string) => private readonly isExceedingLimit = (item: string) =>
!!this.spec.maxLength && !!this.spec.maxLength &&
@@ -44,6 +45,6 @@ export class FormMultiselectComponent extends Control<
@tuiPure @tuiPure
private memoize(value: null | readonly string[]): string[] { private memoize(value: null | readonly string[]): string[] {
return value?.map(key => this.spec.values[key]) || [] return value?.map(key => this.spec.values[key] || '') || []
} }
} }

View File

@@ -14,7 +14,8 @@ export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
readonly disabledItemHandler = (item: string) => readonly disabledItemHandler = (item: string) =>
Array.isArray(this.spec.disabled) && Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item]) !!this.inverted[item] &&
this.spec.disabled.includes(this.inverted[item]!)
get disabled(): boolean { get disabled(): boolean {
return typeof this.spec.disabled === 'string' return typeof this.spec.disabled === 'string'

View File

@@ -6,6 +6,6 @@
<tui-elastic-container class="g-form-group" formGroupName="value"> <tui-elastic-container class="g-form-group" formGroupName="value">
<form-group <form-group
class="group" class="group"
[spec]="(union && spec.variants[union].spec) || {}" [spec]="(union && spec.variants[union]?.spec) || {}"
></form-group> ></form-group>
</tui-elastic-container> </tui-elastic-container>

View File

@@ -38,11 +38,11 @@ export class FormUnionComponent implements OnChanges {
@tuiPure @tuiPure
onUnion(union: string) { onUnion(union: string) {
this.values[this.union] = this.form.control.controls['value'].value this.values[this.union] = this.form.control.controls['value']?.value
this.form.control.setControl( this.form.control.setControl(
'value', 'value',
this.formService.getFormGroup( this.formService.getFormGroup(
union ? this.spec.variants[union].spec : {}, union ? this.spec.variants[union]?.spec || {} : {},
[], [],
this.values[union], this.values[union],
), ),

View File

@@ -71,22 +71,14 @@ type ClearnetForm = {
Make {{ isPublic() ? 'private' : 'public' }} Make {{ isPublic() ? 'private' : 'public' }}
</button> </button>
@if (clearnet().length) { @if (clearnet().length) {
<button <button tuiButton iconStart="@tui.plus" (click)="add()">Add</button>
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
Add
</button>
} }
</header> </header>
@if (clearnet().length) { @if (clearnet().length) {
<table [appTable]="['Domain', 'ACME', 'URL', '']"> <table [appTable]="['ACME', 'URL', '']">
@for (address of clearnet(); track $index) { @for (address of clearnet(); track $index) {
<tr> <tr>
<td [style.width.rem]="15">{{ address.label }}</td> <td [style.width.rem]="12">{{ address.acme | acme }}</td>
<td>{{ address.acme | acme }}</td>
<td>{{ address.url | mask }}</td> <td>{{ address.url | mask }}</td>
<td [actions]="address.url"> <td [actions]="address.url">
<button <button

View File

@@ -29,13 +29,14 @@ export function getAddresses(
tor: AddressDetails[] tor: AddressDetails[]
} { } {
const addressInfo = serviceInterface.addressInfo const addressInfo = serviceInterface.addressInfo
const hostnames = host.hostnameInfo[addressInfo.internalPort].filter( const hostnames =
h => host.hostnameInfo[addressInfo.internalPort]?.filter(
config.isLocalhost() || h =>
h.kind !== 'ip' || config.isLocalhost() ||
h.hostname.kind !== 'ipv6' || h.kind !== 'ip' ||
!h.hostname.value.startsWith('fe80::'), h.hostname.kind !== 'ipv6' ||
) !h.hostname.value.startsWith('fe80::'),
) || []
if (config.isLocalhost()) { if (config.isLocalhost()) {
const local = hostnames.find( const local = hostnames.find(
@@ -67,11 +68,10 @@ export function getAddresses(
addresses.forEach(url => { addresses.forEach(url => {
if (h.kind === 'onion') { if (h.kind === 'onion') {
tor.push({ tor.push({
label: `Tor${ label:
addresses.length > 1 addresses.length > 1
? ` (${new URL(url).protocol.replace(':', '').toUpperCase()})` ? new URL(url).protocol.replace(':', '').toUpperCase()
: '' : '',
}`,
url, url,
}) })
} else { } else {
@@ -79,14 +79,10 @@ export function getAddresses(
if (h.public) { if (h.public) {
clearnet.push({ clearnet.push({
label:
hostnameKind == 'domain'
? 'Domain'
: `${h.networkInterfaceId} (${hostnameKind})`,
url, url,
acme: acme:
hostnameKind == 'domain' hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme ? host.domains[h.hostname.domain]?.acme || null
: null, // @TODO Matt make sure this is handled correctly - looks like ACME settings aren't built yet anyway, but ACME settings aren't *available* for public IPs : null, // @TODO Matt make sure this is handled correctly - looks like ACME settings aren't built yet anyway, but ACME settings aren't *available* for public IPs
}) })
} else { } else {
@@ -128,7 +124,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
} }
export type AddressDetails = { export type AddressDetails = {
label: string label?: string
url: string url: string
acme?: string | null acme?: string | null
} }

View File

@@ -29,7 +29,7 @@ import { MaskPipe } from './mask.pipe'
<table [appTable]="['Network Interface', 'URL', '']"> <table [appTable]="['Network Interface', 'URL', '']">
@for (address of local(); track $index) { @for (address of local(); track $index) {
<tr> <tr>
<td [style.width.rem]="15">{{ address.label }}</td> <td [style.width.rem]="12">{{ address.label }}</td>
<td>{{ address.url | mask }}</td> <td>{{ address.url | mask }}</td>
<td [actions]="address.url"></td> <td [actions]="address.url"></td>
</tr> </tr>

View File

@@ -15,7 +15,12 @@ import {
TuiLink, TuiLink,
TuiOption, TuiOption,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit' import {
TUI_CONFIRM,
TuiFade,
TuiFluidTypography,
TuiTooltip,
} from '@taiga-ui/kit'
import { defaultIfEmpty, firstValueFrom } from 'rxjs' import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import { import {
FormComponent, FormComponent,
@@ -69,8 +74,12 @@ type OnionForm = {
<table [appTable]="['Protocol', 'URL', '']"> <table [appTable]="['Protocol', 'URL', '']">
@for (address of tor(); track $index) { @for (address of tor(); track $index) {
<tr> <tr>
<td [style.width.rem]="15">{{ address.label }}</td> <td [style.width.rem]="12">{{ address.label }}</td>
<td>{{ address.url | mask }}</td> <td>
<div [tuiFluidTypography]="[0.625, 0.8125]" tuiFade>
{{ address.url | mask }}
</div>
</td>
<td [actions]="address.url"> <td [actions]="address.url">
<button <button
tuiButton tuiButton
@@ -99,6 +108,12 @@ type OnionForm = {
</app-placeholder> </app-placeholder>
} }
`, `,
styles: `
[tuiFade] {
white-space: nowrap;
max-width: 30rem;
}
`,
host: { class: 'g-card' }, host: { class: 'g-card' },
imports: [ imports: [
TuiButton, TuiButton,
@@ -111,6 +126,8 @@ type OnionForm = {
PlaceholderComponent, PlaceholderComponent,
MaskPipe, MaskPipe,
InterfaceActionsComponent, InterfaceActionsComponent,
TuiFade,
TuiFluidTypography,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View File

@@ -15,7 +15,7 @@ export class LogsFetchDirective {
@Output() @Output()
readonly logsFetch = defer(() => this.observer).pipe( readonly logsFetch = defer(() => this.observer).pipe(
filter(([{ isIntersecting }]) => isIntersecting && !this.component.scroll), filter(([entry]) => !!entry?.isIntersecting && !this.component.scroll),
switchMap(() => switchMap(() =>
from( from(
this.component.fetchLogs({ this.component.fetchLogs({

View File

@@ -2,7 +2,7 @@
<section <section
class="top" class="top"
waIntersectionObserver waIntersectionObserver
(waIntersectionObservee)="onLoading($event[0].isIntersecting)" (waIntersectionObservee)="onLoading(!!$event[0]?.isIntersecting)"
(logsFetch)="onPrevious($event)" (logsFetch)="onPrevious($event)"
> >
@if (loading) { @if (loading) {
@@ -41,7 +41,7 @@
class="bottom" class="bottom"
waIntersectionObserver waIntersectionObserver
(waIntersectionObservee)=" (waIntersectionObservee)="
setScroll($event[$event.length - 1].isIntersecting) setScroll(!!$event[$event.length - 1]?.isIntersecting)
" "
></section> ></section>
</tui-scrollbar> </tui-scrollbar>

View File

@@ -86,7 +86,7 @@ function toNavigationItem(
const item = SYSTEM_UTILITIES[id] const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id) const routerLink = toRouterLink(id)
return SYSTEM_UTILITIES[id] return item
? { ? {
icon: item.icon, icon: item.icon,
title: item.title, title: item.title,
@@ -94,7 +94,7 @@ function toNavigationItem(
} }
: { : {
icon: packages[id]?.icon, icon: packages[id]?.icon,
title: getManifest(packages[id]).title, title: getManifest(packages[id]!).title,
routerLink, routerLink,
} }
} }

View File

@@ -5,13 +5,7 @@ import {
inject, inject,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
TuiAppearance,
TuiButton,
TuiIcon,
TuiLink,
TuiTitle,
} from '@taiga-ui/core'
import { TuiCardMedium } from '@taiga-ui/layout' import { TuiCardMedium } from '@taiga-ui/layout'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component' import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
@@ -37,7 +31,7 @@ interface Log {
> >
Back Back
</button> </button>
{{ logs[key].title }} {{ logs[key]?.title }}
} @else { } @else {
Logs Logs
} }
@@ -55,9 +49,9 @@ interface Log {
> >
Close Close
</button> </button>
{{ logs[key].title }} {{ logs[key]?.title }}
</strong> </strong>
<p tuiSubtitle>{{ logs[key].subtitle }}</p> <p tuiSubtitle>{{ logs[key]?.subtitle }}</p>
</header> </header>
@for (log of logs | keyvalue; track $index) { @for (log of logs | keyvalue; track $index) {
@if (log.key === current()) { @if (log.key === current()) {
@@ -169,7 +163,6 @@ interface Log {
TuiCardMedium, TuiCardMedium,
TuiIcon, TuiIcon,
TuiAppearance, TuiAppearance,
TuiLink,
TuiButton, TuiButton,
], ],
}) })

View File

@@ -17,7 +17,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
selector: 'tr[actionRequest]', selector: 'tr[actionRequest]',
template: ` template: `
<td> <td>
<tui-avatar size="xs"><img [src]="pkg().icon" alt="" /></tui-avatar> <tui-avatar size="xs"><img [src]="pkg()?.icon" alt="" /></tui-avatar>
<span>{{ title() }}</span> <span>{{ title() }}</span>
</td> </td>
<td> <td>
@@ -35,7 +35,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
</td> </td>
<td> <td>
<button tuiButton (click)="handle()"> <button tuiButton (click)="handle()">
{{ pkg().actions[actionRequest().actionId].name }} {{ pkg()?.actions?.[actionRequest().actionId]?.name }}
</button> </button>
</td> </td>
`, `,
@@ -79,19 +79,27 @@ export class ServiceActionRequestComponent {
readonly services = input.required<Record<string, PackageDataEntry>>() readonly services = input.required<Record<string, PackageDataEntry>>()
readonly pkg = computed(() => this.services()[this.actionRequest().packageId]) readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
readonly title = computed(() => getManifest(this.pkg()).title) readonly title = computed((pkg = this.pkg()) => pkg && getManifest(pkg).title)
async handle() { async handle() {
const title = this.title()
const pkg = this.pkg()
const metadata = pkg?.actions[this.actionRequest().actionId]
if (!title || !pkg || !metadata) {
return
}
this.actionService.present({ this.actionService.present({
pkgInfo: { pkgInfo: {
id: this.actionRequest().packageId, id: this.actionRequest().packageId,
title: this.title(), title,
mainStatus: this.pkg().status.main, mainStatus: pkg.status.main,
icon: this.pkg().icon, icon: pkg.icon,
}, },
actionInfo: { actionInfo: {
id: this.actionRequest().actionId, id: this.actionRequest().actionId,
metadata: this.pkg().actions[this.actionRequest().actionId], metadata,
}, },
requestInfo: this.actionRequest(), requestInfo: this.actionRequest(),
}) })

View File

@@ -37,6 +37,7 @@ import { ServiceActionRequestComponent } from './action-request.component'
`, `,
styles: ` styles: `
:host { :host {
min-height: 12rem;
grid-column: span 6; grid-column: span 6;
} }
`, `,
@@ -50,7 +51,13 @@ export class ServiceActionRequestsComponent {
readonly requests = computed(() => readonly requests = computed(() =>
Object.values(this.pkg().requestedActions) Object.values(this.pkg().requestedActions)
.filter(r => r.active) // @TODO Alex uncomment filter line below to produce infinite loop on service details page when dependency not installed. This means the page is infinitely trying to re-render
// .filter(r => r.active)
.filter(
r =>
this.services()[r.request.packageId]?.actions[r.request.actionId] &&
r.active,
)
.sort((a, b) => a.request.severity.localeCompare(b.request.severity)), .sort((a, b) => a.request.severity.localeCompare(b.request.severity)),
) )
} }

View File

@@ -16,10 +16,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
@Component({ @Component({
selector: 'service-controls', selector: 'service-controls',
template: ` template: `
@if (['running', 'starting', 'restarting'].includes(status)) { @if (status && ['running', 'starting', 'restarting'].includes(status)) {
<button <button
tuiButton tuiButton
appearance="secondary-destructive" appearance="primary-destructive"
iconStart="@tui.square" iconStart="@tui.square"
(click)="controls.stop(manifest())" (click)="controls.stop(manifest())"
> >
@@ -41,7 +41,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button <button
tuiButton tuiButton
iconStart="@tui.play" iconStart="@tui.play"
(click)="controls.start(manifest(), !!hasUnmet)" (click)="controls.start(manifest(), !!hasUnmet())"
> >
Start Start
</button> </button>
@@ -50,11 +50,12 @@ import { getManifest } from 'src/app/utils/get-package-data'
styles: [ styles: [
` `
:host { :host {
width: 100%;
max-width: 18rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
justify-content: center; justify-content: center;
max-inline-size: 100%;
margin-block-start: 1rem; margin-block-start: 1rem;
&:nth-child(3) { &:nth-child(3) {
@@ -62,6 +63,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
} }
} }
[tuiButton] {
flex: 1;
min-width: fit-content;
}
:host-context(tui-root._mobile) { :host-context(tui-root._mobile) {
display: flex; display: flex;
margin: 0; margin: 0;
@@ -85,12 +91,13 @@ export class ServiceControlsComponent {
pkg!: PackageDataEntry pkg!: PackageDataEntry
@Input({ required: true }) @Input({ required: true })
status!: PrimaryStatus status?: PrimaryStatus
readonly manifest = computed(() => getManifest(this.pkg)) readonly manifest = computed(() => getManifest(this.pkg))
readonly controls = inject(ControlsService) readonly controls = inject(ControlsService)
// @TODO Alex observable in signal?
readonly hasUnmet = computed(() => readonly hasUnmet = computed(() =>
this.errors.getPkgDepErrors$(this.manifest().id).pipe( this.errors.getPkgDepErrors$(this.manifest().id).pipe(
map(errors => map(errors =>

View File

@@ -5,6 +5,7 @@ import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit' import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Component({ @Component({
@@ -20,6 +21,11 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
<tui-avatar><img alt="" [src]="d.value.icon" /></tui-avatar> <tui-avatar><img alt="" [src]="d.value.icon" /></tui-avatar>
<span tuiTitle> <span tuiTitle>
{{ d.value.title }} {{ d.value.title }}
@if (getError(d.key); as error) {
<span tuiSubtitle class="g-warning">{{ error }}</span>
} @else {
<span tuiSubtitle class="g-positive">Satisfied</span>
}
<span tuiSubtitle>{{ d.value.versionRange }}</span> <span tuiSubtitle>{{ d.value.versionRange }}</span>
</span> </span>
<tui-icon icon="@tui.arrow-right" /> <tui-icon icon="@tui.arrow-right" />
@@ -30,6 +36,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
`, `,
styles: ` styles: `
:host { :host {
min-height: 12rem;
grid-column: span 3; grid-column: span 3;
} }
`, `,
@@ -52,4 +59,32 @@ export class ServiceDependenciesComponent {
@Input({ required: true }) @Input({ required: true })
services: Record<string, PackageDataEntry> = {} services: Record<string, PackageDataEntry> = {}
@Input({ required: true })
errors: PkgDependencyErrors = {}
getError(id: string): string {
const depError = this.errors[id]
if (!depError) {
return ''
}
switch (depError.type) {
case 'notInstalled':
return 'Not installed'
case 'incorrectVersion':
return 'Incorrect version'
case 'notRunning':
return 'Not running'
case 'actionRequired':
return 'Action required'
case 'healthChecksFailed':
return 'Required health check not passing'
case 'transitive':
return 'Dependency has a dependency issue'
default:
return 'Unknown error'
}
}
} }

View File

@@ -30,6 +30,7 @@ import { ServiceHealthCheckComponent } from './health-check.component'
`, `,
styles: ` styles: `
:host { :host {
min-height: 12rem;
grid-column: span 3; grid-column: span 3;
} }
`, `,

View File

@@ -64,7 +64,7 @@ export class ServiceInterfacesComponent {
return { return {
...value, ...value,
public: !!host?.bindings[value.addressInfo.internalPort].net.public, public: !!host?.bindings[value.addressInfo.internalPort]?.net.public,
addresses: host ? getAddresses(value, host, this.config) : {}, addresses: host ? getAddresses(value, host, this.config) : {},
routerLink: `./interface/${id}`, routerLink: `./interface/${id}`,
} }

View File

@@ -27,7 +27,7 @@ import {
<small>See below</small> <small>See below</small>
} }
@if (rendering.showDots) { @if (rendering?.showDots) {
<span class="loading-dots"></span> <span class="loading-dots"></span>
} }
</h3> </h3>
@@ -45,6 +45,7 @@ import {
font: var(--tui-font-heading-4); font: var(--tui-font-heading-4);
font-weight: normal; font-weight: normal;
margin: 0; margin: 0;
text-align: center;
} }
div { div {
@@ -76,6 +77,10 @@ import {
padding: 0.5rem 0; padding: 0.5rem 0;
} }
h3 {
text-align: left;
}
small { small {
text-align: left; text-align: left;
} }
@@ -89,7 +94,7 @@ import {
}) })
export class ServiceStatusComponent { export class ServiceStatusComponent {
@Input({ required: true }) @Input({ required: true })
status!: PrimaryStatus status?: PrimaryStatus
@Input() @Input()
installingInfo?: InstallingInfo installingInfo?: InstallingInfo
@@ -98,13 +103,13 @@ export class ServiceStatusComponent {
connected = false connected = false
get text() { get text() {
return this.connected ? this.rendering.display : 'Unknown' return this.connected ? this.rendering?.display : 'Unknown'
} }
get class(): string | null { get class(): string | null {
if (!this.connected) return null if (!this.connected) return null
switch (this.rendering.color) { switch (this.rendering?.color) {
case 'danger': case 'danger':
return 'g-negative' return 'g-negative'
case 'warning': case 'warning':
@@ -119,7 +124,7 @@ export class ServiceStatusComponent {
} }
get rendering() { get rendering() {
return PrimaryRendering[this.status] return this.status && PrimaryRendering[this.status]
} }
getText(progress: T.Progress): string { getText(progress: T.Progress): string {

View File

@@ -30,15 +30,6 @@ const RUNNING = ['running', 'starting', 'restarting']
> >
Stop Stop
</button> </button>
<button
tuiIconButton
iconStart="@tui.rotate-cw"
[disabled]="status().primary !== 'running'"
(click)="controls.restart(manifest())"
>
Restart
</button>
} @else { } @else {
<button <button
*tuiLet="hasUnmet() | async as hasUnmet" *tuiLet="hasUnmet() | async as hasUnmet"

View File

@@ -181,7 +181,7 @@ export class ActionInputModal {
.filter( .filter(
id => id =>
id !== this.pkgInfo.id && id !== this.pkgInfo.id &&
Object.values(packages[id].requestedActions).some( Object.values(packages[id]!.requestedActions).some(
({ request, active }) => ({ request, active }) =>
!active && !active &&
request.severity === 'critical' && request.severity === 'critical' &&
@@ -201,7 +201,7 @@ export class ActionInputModal {
const message = const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>' 'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const content = `${message}${breakages.map( const content = `${message}${breakages.map(
id => `<li><b>${getManifest(packages[id]).title}</b></li>`, id => `<li><b>${getManifest(packages[id]!).title}</b></li>`,
)}</ul>` )}</ul>`
const data: TuiConfirmData = { content, yes: 'Continue', no: 'Cancel' } const data: TuiConfirmData = { content, yes: 'Continue', no: 'Cancel' }

View File

@@ -14,39 +14,44 @@ import { map } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
import { import {
AdditionalItem,
FALLBACK_URL, FALLBACK_URL,
ServiceAdditionalItemComponent, ServiceAdditionalItemComponent,
} from '../components/additional-item.component' } from '../components/additional-item.component'
import { KeyValuePipe } from '@angular/common'
@Component({ @Component({
template: ` template: `
<section class="g-card"> @for (group of items() | keyvalue; track $index) {
@for (additional of items(); track $index) { <section class="g-card">
@if (additional.description.startsWith('http')) { <header>{{ group.key }}</header>
<a tuiCell [additionalItem]="additional"></a> @for (additional of group.value; track $index) {
} @else { @if (additional.description.startsWith('http')) {
<button <a tuiCell [additionalItem]="additional"></a>
tuiCell } @else {
[style.pointer-events]="!additional.icon ? 'none' : null" <button
[additionalItem]="additional" tuiCell
(click)="additional.action?.()" [style.pointer-events]="!additional.icon ? 'none' : null"
></button> [additionalItem]="additional"
(click)="additional.action?.()"
></button>
}
} }
} </section>
</section> }
`, `,
styles: ` styles: `
section { section {
max-width: 42rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 36rem; margin-bottom: 2rem;
padding: 0.5rem 1rem;
} }
`, `,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
host: { class: 'g-subpage' }, imports: [ServiceAdditionalItemComponent, TuiCell, KeyValuePipe],
imports: [ServiceAdditionalItemComponent, TuiCell],
}) })
export default class ServiceAboutRoute { export default class ServiceAboutRoute {
private readonly copyService = inject(CopyService) private readonly copyService = inject(CopyService)
@@ -55,56 +60,62 @@ export default class ServiceAboutRoute {
{ label: 'License', size: 'l' }, { label: 'License', size: 'l' },
) )
readonly items = toSignal( readonly items = toSignal<Record<string, AdditionalItem[]>>(
inject<PatchDB<DataModel>>(PatchDB) inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId()) .watch$('packageData', getPkgId())
.pipe( .pipe(
map(pkg => { map(pkg => {
const manifest = getManifest(pkg) const manifest = getManifest(pkg)
return [ return {
{ General: [
name: 'Version', {
description: manifest.version, name: 'Version',
}, description: manifest.version,
{ icon: '@tui.copy',
name: 'Git Hash', action: () => this.copyService.copy(manifest.version),
description: manifest.gitHash || 'Unknown', },
icon: manifest.gitHash ? '@tui.copy' : '', {
action: () => name: 'Git Hash',
manifest.gitHash && this.copyService.copy(manifest.gitHash), description: manifest.gitHash || 'Unknown',
}, icon: manifest.gitHash ? '@tui.copy' : '',
{ action: () =>
name: 'License', manifest.gitHash && this.copyService.copy(manifest.gitHash),
description: manifest.license, },
icon: '@tui.chevron-right', {
action: () => this.markdown.subscribe(), name: 'License',
}, description: manifest.license,
{ icon: '@tui.chevron-right',
name: 'Website', action: () => this.markdown.subscribe(),
description: manifest.marketingSite || FALLBACK_URL, },
}, ],
{ Links: [
name: 'Donation Link', {
description: manifest.donationUrl || FALLBACK_URL, name: 'Installed From',
}, description: pkg.registry || FALLBACK_URL,
{ },
name: 'Source Repository', {
description: manifest.upstreamRepo, name: 'Service Repository',
}, description: manifest.upstreamRepo,
{ },
name: 'Support Site', {
description: manifest.supportSite || FALLBACK_URL, name: 'Package Repository',
}, description: manifest.wrapperRepo,
{ },
name: 'Registry', {
description: pkg.registry || FALLBACK_URL, name: 'Marketing Site',
}, description: manifest.marketingSite || FALLBACK_URL,
{ },
name: 'Binary Source', {
description: manifest.wrapperRepo, name: 'Support Site',
}, description: manifest.supportSite || FALLBACK_URL,
] },
{
name: 'Donation Link',
description: manifest.donationUrl || FALLBACK_URL,
},
],
}
}), }),
), ),
) )

View File

@@ -12,24 +12,11 @@ import { StandardActionsService } from 'src/app/services/standard-actions.servic
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component' import { ServiceActionComponent } from '../components/action.component'
const OTHER = 'Other Custom Actions' const OTHER = 'Custom Actions'
@Component({ @Component({
template: ` template: `
@if (package(); as pkg) { @if (package(); as pkg) {
<section class="g-card">
<header>Standard Actions</header>
<button
tuiCell
[action]="rebuild"
(click)="service.rebuild(pkg.manifest.id)"
></button>
<button
tuiCell
[action]="uninstall"
(click)="service.uninstall(pkg.manifest)"
></button>
</section>
@for (group of pkg.actions | keyvalue; track $index) { @for (group of pkg.actions | keyvalue; track $index) {
@if (group.value.length) { @if (group.value.length) {
<section class="g-card"> <section class="g-card">
@@ -46,6 +33,19 @@ const OTHER = 'Other Custom Actions'
</section> </section>
} }
} }
<section class="g-card">
<header>Standard Actions</header>
<button
tuiCell
[action]="rebuild"
(click)="service.rebuild(pkg.manifest.id)"
></button>
<button
tuiCell
[action]="uninstall"
(click)="service.uninstall(pkg.manifest)"
></button>
</section>
} }
`, `,
styles: ` styles: `
@@ -80,8 +80,8 @@ export default class ServiceActionsRoute {
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>> Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>( >(
(acc, [id]) => { (acc, [id]) => {
const action = { id, ...pkg.actions[id] } const action = { id, ...pkg.actions[id]! }
const group = pkg.actions[id].group || OTHER const group = pkg.actions[id]?.group || OTHER
const current = acc[group] || [] const current = acc[group] || []
return { ...acc, [group]: current.concat(action) } return { ...acc, [group]: current.concat(action) }

View File

@@ -1,4 +1,3 @@
import { NgTemplateOutlet } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -11,7 +10,7 @@ import { RouterLink } from '@angular/router'
import { getPkgId } from '@start9labs/shared' import { getPkgId } from '@start9labs/shared'
import { TuiItem } from '@taiga-ui/cdk' import { TuiItem } from '@taiga-ui/cdk'
import { TuiButton, TuiLink } from '@taiga-ui/core' import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit' import { TuiBreadcrumbs } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils' import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
@@ -59,8 +58,6 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiBreadcrumbs, TuiBreadcrumbs,
TuiItem, TuiItem,
TuiLink, TuiLink,
TuiBadge,
NgTemplateOutlet,
InterfaceStatusComponent, InterfaceStatusComponent,
], ],
}) })
@@ -84,11 +81,16 @@ export default class ServiceInterfaceRoute {
const { serviceInterfaces, hosts } = pkg const { serviceInterfaces, hosts } = pkg
const item = serviceInterfaces[this.interfaceId()] const item = serviceInterfaces[this.interfaceId()]
const host = hosts[item.addressInfo.hostId] const key = item?.addressInfo.hostId || ''
const host = hosts[key]
if (!host || !item) {
return
}
return { return {
...item, ...item,
public: host.bindings[item.addressInfo.internalPort].net.public, public: !!host?.bindings[item.addressInfo.internalPort]?.net.public,
addresses: getAddresses(item, host, this.config), addresses: getAddresses(item, host, this.config),
} }
}) })

View File

@@ -10,9 +10,10 @@ import { ActivatedRoute } from '@angular/router'
import { isEmptyObject } from '@start9labs/shared' import { isEmptyObject } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs' import { map, of } from 'rxjs'
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component' import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service' import { ConnectionService } from 'src/app/services/connection.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { import {
DataModel, DataModel,
PackageDataEntry, PackageDataEntry,
@@ -31,31 +32,36 @@ import { ServiceStatusComponent } from '../components/status.component'
template: ` template: `
<service-status <service-status
[connected]="!!connected()" [connected]="!!connected()"
[installingInfo]="pkg().stateInfo.installingInfo" [installingInfo]="pkg()?.stateInfo?.installingInfo"
[status]="status()" [status]="status()"
> >
@if ($any(pkg().status).started; as started) { @if ($any(pkg()?.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p> <p class="g-secondary" [appUptime]="started"></p>
} }
@if (installed() && connected()) { @if (installed() && connected() && pkg(); as pkg) {
<service-controls [pkg]="pkg()" [status]="status()" /> <service-controls [pkg]="pkg" [status]="status()" />
} }
</service-status> </service-status>
@if (installed()) { @if (installed() && pkg(); as pkg) {
@if (pkg().status.main === 'error') { @if (pkg.status.main === 'error') {
<service-error [pkg]="pkg()" /> <service-error [pkg]="pkg" />
}
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
<service-dependencies
[pkg]="pkg"
[services]="services()"
[errors]="errors"
/>
} }
<service-interfaces [pkg]="pkg()" [disabled]="status() !== 'running'" />
<service-dependencies [pkg]="pkg()" [services]="services()" />
<service-health-checks [checks]="health()" /> <service-health-checks [checks]="health()" />
<service-action-requests [pkg]="pkg()" [services]="services()" /> <service-action-requests [pkg]="pkg" [services]="services() || {}" />
} }
@if (installing()) { @if (installing() && pkg(); as pkg) {
@for ( @for (
item of pkg().stateInfo.installingInfo?.progress?.phases; item of pkg.stateInfo.installingInfo?.progress?.phases;
track $index track $index
) { ) {
<p [progress]="item.progress">{{ item.name }}</p> <p [progress]="item.progress">{{ item.name }}</p>
@@ -100,6 +106,7 @@ import { ServiceStatusComponent } from '../components/status.component'
], ],
}) })
export class ServiceRoute { export class ServiceRoute {
private readonly errorService = inject(DepErrorService)
protected readonly connected = toSignal(inject(ConnectionService)) protected readonly connected = toSignal(inject(ConnectionService))
protected readonly id = toSignal( protected readonly id = toSignal(
@@ -111,10 +118,14 @@ export class ServiceRoute {
{ initialValue: {} as Record<string, PackageDataEntry> }, { initialValue: {} as Record<string, PackageDataEntry> },
) )
protected readonly errors = computed((id = this.id()) =>
id ? this.errorService.getPkgDepErrors$(id) : of({}),
)
protected readonly pkg = computed(() => this.services()[this.id() || '']) protected readonly pkg = computed(() => this.services()[this.id() || ''])
protected readonly health = computed(() => protected readonly health = computed((pkg = this.pkg()) =>
this.pkg() ? toHealthCheck(this.pkg().status) : [], pkg ? toHealthCheck(pkg.status) : [],
) )
protected readonly status = computed((pkg = this.pkg()) => protected readonly status = computed((pkg = this.pkg()) =>

View File

@@ -120,9 +120,9 @@ export default class SystemAcmeComponent {
this.patch.watch$('serverInfo', 'network', 'acme').pipe( this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme => map(acme =>
Object.keys(acme).map(url => { Object.keys(acme).map(url => {
const contact = acme[url].contact.map(mailto => const contact =
mailto.replace('mailto:', ''), acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
) []
return { return {
url, url,
contact, contact,

View File

@@ -136,7 +136,7 @@ export class BackupsBackupComponent {
// existing backup // existing backup
} else { } else {
try { try {
argon2.verify(entry.startOs[id].passwordHash!, password) argon2.verify(entry.startOs[id]?.passwordHash!, password)
await this.createBackup(password) await this.createBackup(password)
} catch { } catch {
this.oldPassword(password) this.oldPassword(password)
@@ -156,7 +156,7 @@ export class BackupsBackupComponent {
private async oldPassword(password: string) { private async oldPassword(password: string) {
const { id } = await getServerInfo(this.patch) const { id } = await getServerInfo(this.patch)
const { passwordHash } = this.context.data.entry.startOs[id] const { passwordHash = '' } = this.context.data.entry.startOs[id] || {}
this.dialogs this.dialogs
.open<string>(PROMPT, PASSWORD_OPTIONS) .open<string>(PROMPT, PASSWORD_OPTIONS)

View File

@@ -68,10 +68,11 @@ export class BackupService {
} }
hasThisBackup({ startOs }: BackupTarget, id: string): boolean { hasThisBackup({ startOs }: BackupTarget, id: string): boolean {
const item = startOs[id]
return ( return (
startOs[id] && !!item &&
Version.parse(startOs[id].version).compare(Version.parse('0.3.6')) !== Version.parse(item.version).compare(Version.parse('0.3.6')) !== 'less'
'less'
) )
} }
} }

View File

@@ -8,9 +8,8 @@ import { ActivatedRoute } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk' import { ISB } from '@start9labs/start-sdk'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAlertService, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiAlertService, TuiButton, TuiIcon } from '@taiga-ui/core'
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit' import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs' import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component' import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
@@ -232,7 +231,7 @@ export class BackupNetworkComponent {
...value, ...value,
}) })
target.entry = Object.values(res)[0] target.entry = Object.values(res)[0]!
this.service.cifs.update(cifs => [...cifs]) this.service.cifs.update(cifs => [...cifs])
return true return true
} catch (e: any) { } catch (e: any) {
@@ -273,7 +272,13 @@ export class BackupNetworkComponent {
.subscribe() .subscribe()
try { try {
const [[id, entry]] = Object.entries(await this.api.addBackupTarget(v)) const [item] = Object.entries(await this.api.addBackupTarget(v))
const [id, entry] = item || []
if (!id || !entry) {
throw 'Invalid response from server'
}
const hasAnyBackup = this.service.hasAnyBackup(entry) const hasAnyBackup = this.service.hasAnyBackup(entry)
const added = { id, entry, hasAnyBackup } const added = { id, entry, hasAnyBackup }
this.service.cifs.update(cifs => [added, ...cifs]) this.service.cifs.update(cifs => [added, ...cifs])

View File

@@ -91,12 +91,12 @@ export class BackupsRecoverComponent {
return Object.keys(backups) return Object.keys(backups)
.map(id => ({ .map(id => ({
...backups[id], ...backups[id]!,
id, id,
installed: !!packageData[id], installed: !!packageData[id],
checked: false, checked: false,
newerOs: newerOs:
Version.parse(backups[id].osVersion).compare( Version.parse(backups[id]?.osVersion || '').compare(
Version.parse(this.config.version), Version.parse(this.config.version),
) === 'greater', ) === 'greater',
})) }))

View File

@@ -142,8 +142,8 @@ export class SnekComponent implements AfterViewInit, OnDestroy {
handleTouchStart(evt: TouchEvent) { handleTouchStart(evt: TouchEvent) {
const firstTouch = this.getTouches(evt)[0] const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch.clientX this.xDown = firstTouch?.clientX
this.yDown = firstTouch.clientY this.yDown = firstTouch?.clientY
} }
handleTouchMove(evt: TouchEvent) { handleTouchMove(evt: TouchEvent) {
@@ -151,8 +151,8 @@ export class SnekComponent implements AfterViewInit, OnDestroy {
return return
} }
var xUp = evt.touches[0].clientX var xUp = evt.touches[0]?.clientX || 0
var yUp = evt.touches[0].clientY var yUp = evt.touches[0]?.clientY || 0
var xDiff = this.xDown - xUp var xDiff = this.xDown - xUp
var yDiff = this.yDown - yUp var yDiff = this.yDown - yUp

View File

@@ -18,7 +18,7 @@ import { EOSService } from 'src/app/services/eos.service'
@Component({ @Component({
template: ` template: `
<h2 style="margin-top: 0">StartOS {{ versions[0].version }}</h2> <h2 style="margin-top: 0">StartOS {{ versions[0]?.version }}</h2>
<h3 style="color: var(--tui-text-secondary); font-weight: normal"> <h3 style="color: var(--tui-text-secondary); font-weight: normal">
Release Notes Release Notes
</h3> </h3>

View File

@@ -72,7 +72,7 @@ export default class StartOsUiComponent {
.pipe( .pipe(
map(host => ({ map(host => ({
...iface, ...iface,
public: host.bindings[iface.addressInfo.internalPort].net.public, public: !!host.bindings[iface.addressInfo.internalPort]?.net.public,
addresses: getAddresses(iface, host, this.config), addresses: getAddresses(iface, host, this.config),
})), })),
), ),

View File

@@ -70,7 +70,13 @@ export default class SystemSessionsComponent {
private readonly sessions$ = from(this.api.getSessions({})) private readonly sessions$ = from(this.api.getSessions({}))
private readonly local$ = new Subject<readonly SessionWithId[]>() private readonly local$ = new Subject<readonly SessionWithId[]>()
readonly current$ = this.sessions$.pipe(map(s => [s.sessions[s.current]])) readonly current$ = this.sessions$.pipe(
map(s => {
const current = s.sessions[s.current]
return current ? [current] : []
}),
)
readonly other$: Observable<readonly SessionWithId[]> = merge( readonly other$: Observable<readonly SessionWithId[]> = merge(
this.local$, this.local$,
this.sessions$.pipe( this.sessions$.pipe(

View File

@@ -10,7 +10,6 @@ import {
StoreIconComponentModule, StoreIconComponentModule,
StoreIdentity, StoreIdentity,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { TuiTable } from '@taiga-ui/addon-table'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk' import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiNotification, TuiTitle } from '@taiga-ui/core' import { TuiButton, TuiNotification, TuiTitle } from '@taiga-ui/core'
import { import {
@@ -88,7 +87,10 @@ interface UpdatesData {
</aside> </aside>
<section class="g-subpage"> <section class="g-subpage">
@if (data()?.errors?.includes(current()?.url || '')) { @if (data()?.errors?.includes(current()?.url || '')) {
<tui-notification appearance="negative"> <tui-notification
appearance="negative"
[style.margin-block-end.rem]="1"
>
Request Failed Request Failed
</tui-notification> </tui-notification>
} }
@@ -222,7 +224,9 @@ export default class UpdatesComponent {
combineLatest({ combineLatest({
hosts: this.marketplaceService hosts: this.marketplaceService
.getKnownHosts$(true) .getKnownHosts$(true)
.pipe(tap(([store]) => !this.isMobile && this.current.set(store))), .pipe(
tap(([store]) => !this.isMobile && store && this.current.set(store)),
),
marketplace: this.marketplaceService.marketplace$, marketplace: this.marketplaceService.marketplace$,
localPkgs: inject<PatchDB<DataModel>>(PatchDB) localPkgs: inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData') .watch$('packageData')

View File

@@ -501,9 +501,9 @@ export class MockApiService extends ApiService {
): Promise<GetPackageRes> { ): Promise<GetPackageRes> {
await pauseFor(2000) await pauseFor(2000)
if (!versionRange || versionRange === '=*') { if (!versionRange || versionRange === '=*') {
return Mock.RegistryPackages[id] return Mock.RegistryPackages[id]!
} else { } else {
return Mock.OtherPackageVersions[id][versionRange] return Mock.OtherPackageVersions[id]![versionRange]!
} }
} }
@@ -870,7 +870,7 @@ export class MockApiService extends ApiService {
this.mockRevision([ this.mockRevision([
{ {
...appPatch[0], ...appPatch[0]!,
value: 'stopped', value: 'stopped',
}, },
]) ])
@@ -1055,18 +1055,18 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/packageData/${params.id}`, path: `/packageData/${params.id}`,
value: { value: {
...Mock.LocalPkgs[params.id], ...Mock.LocalPkgs[params.id]!,
stateInfo: { stateInfo: {
// if installing // if installing
// state: 'installing', // state: 'installing',
// if updating // if updating
state: 'updating', state: 'updating',
manifest: mockPatchData.packageData[params.id].stateInfo.manifest!, manifest: mockPatchData.packageData[params.id]?.stateInfo.manifest!,
// both // both
installingInfo: { installingInfo: {
newManifest: Mock.LocalPkgs[params.id].stateInfo.manifest, newManifest: Mock.LocalPkgs[params.id]?.stateInfo.manifest!,
progress: PROGRESS, progress: PROGRESS,
}, },
}, },
@@ -1123,11 +1123,11 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/packageData/${id}`, path: `/packageData/${id}`,
value: { value: {
...Mock.LocalPkgs[id], ...Mock.LocalPkgs[id]!,
stateInfo: { stateInfo: {
state: 'restoring', state: 'restoring',
installingInfo: { installingInfo: {
newManifest: Mock.LocalPkgs[id].stateInfo.manifest!, newManifest: Mock.LocalPkgs[id]?.stateInfo.manifest!,
progress: PROGRESS, progress: PROGRESS,
}, },
}, },
@@ -1640,7 +1640,7 @@ export class MockApiService extends ApiService {
) { ) {
await pauseFor(2000) await pauseFor(2000)
progress.phases[i].progress = true progress.phases[i]!.progress = true
if ( if (
progress.overall && progress.overall &&
@@ -1671,7 +1671,7 @@ export class MockApiService extends ApiService {
if (phase.progress.done === phase.progress.total) { if (phase.progress.done === phase.progress.total) {
await pauseFor(250) await pauseFor(250)
progress.phases[i].progress = true progress.phases[i]!.progress = true
} }
} }
} }
@@ -1777,7 +1777,7 @@ export class MockApiService extends ApiService {
path: `/packageData/${id}/stateInfo`, path: `/packageData/${id}/stateInfo`,
value: { value: {
state: 'installed', state: 'installed',
manifest: Mock.LocalPkgs[id].stateInfo.manifest, manifest: Mock.LocalPkgs[id]?.stateInfo.manifest!,
}, },
}, },
] ]

View File

@@ -47,7 +47,8 @@ export class BadgeService {
return ( return (
!curr[id] || !curr[id] ||
(p.stateInfo.installingInfo && !curr[id].stateInfo.installingInfo) (p.stateInfo.installingInfo &&
!curr[id]?.stateInfo.installingInfo)
) )
}), }),
), ),
@@ -70,7 +71,7 @@ export class BadgeService {
local[id] && local[id] &&
this.exver.compareExver( this.exver.compareExver(
version, version,
getManifest(local[id]).version, getManifest(local[id]!).version,
) === 1 ) === 1
? result.add(id) ? result.add(id)
: result, : result,

View File

@@ -59,7 +59,7 @@ export class ConfigService {
(this.hostname.startsWith('192.168.') || (this.hostname.startsWith('192.168.') ||
this.hostname.startsWith('10.') || this.hostname.startsWith('10.') ||
(this.hostname.startsWith('172.') && (this.hostname.startsWith('172.') &&
!![this.hostname.split('.').map(Number)[1]].filter( !![this.hostname.split('.').map(Number)[1] || NaN].filter(
n => n >= 16 && n < 32, n => n >= 16 && n < 32,
).length)) ).length))
} }
@@ -107,13 +107,14 @@ export class ConfigService {
if (!host) return '' if (!host) return ''
let hostnameInfo = host.hostnameInfo[ui.addressInfo.internalPort] let hostnameInfo = host.hostnameInfo[ui.addressInfo.internalPort]
hostnameInfo = hostnameInfo.filter( hostnameInfo =
h => hostnameInfo?.filter(
this.isLocalhost() || h =>
h.kind !== 'ip' || this.isLocalhost() ||
h.hostname.kind !== 'ipv6' || h.kind !== 'ip' ||
!h.hostname.value.startsWith('fe80::'), h.hostname.kind !== 'ipv6' ||
) !h.hostname.value.startsWith('fe80::'),
) || []
if (this.isLocalhost()) { if (this.isLocalhost()) {
const local = hostnameInfo.find( const local = hostnameInfo.find(
h => h.kind === 'ip' && h.hostname.kind === 'local', h => h.kind === 'ip' && h.hostname.kind === 'local',

View File

@@ -2,9 +2,16 @@ import { inject, Injectable } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core' import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM } from '@taiga-ui/kit' import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, filter, firstValueFrom } from 'rxjs' import {
defaultIfEmpty,
defer,
filter,
firstValueFrom,
of,
switchMap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAllPackages } from 'src/app/utils/get-package-data' import { getAllPackages } from 'src/app/utils/get-package-data'
@@ -20,53 +27,16 @@ export class ControlsService {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
async start(manifest: T.Manifest, unmet: boolean): Promise<void> { async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
const deps = `${manifest.title} has unmet dependencies. It will not work as expected.` const deps = `${title} has unmet dependencies. It will not work as expected.`
if ( if (
(!unmet || (await this.alert(deps))) && (unmet && !(await this.alert(deps))) ||
(!manifest.alerts.start || (await this.alert(manifest.alerts.start))) (alerts.start && !(await this.alert(alerts.start)))
) { ) {
this.doStart(manifest.id) return
}
}
async stop({ id, title, alerts }: T.Manifest): Promise<void> {
let content = alerts.stop || ''
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
content = content ? `${content}.\n\n${depMessage}` : depMessage
} }
if (content) {
this.dialogs
.open(TUI_CONFIRM, getOptions(content, 'Stop'))
.pipe(filter(Boolean))
.subscribe(() => this.doStop(id))
} else {
this.doStop(id)
}
}
async restart({ id, title }: T.Manifest): Promise<void> {
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
this.dialogs
.open(
TUI_CONFIRM,
getOptions(
`Services that depend on ${title} may temporarily experiences issues`,
'Restart',
),
)
.pipe(filter(Boolean))
.subscribe(() => this.doRestart(id))
} else {
this.doRestart(id)
}
}
private async doStart(id: string): Promise<void> {
const loader = this.loader.open(`Starting...`).subscribe() const loader = this.loader.open(`Starting...`).subscribe()
try { try {
@@ -78,28 +48,55 @@ export class ControlsService {
} }
} }
private async doStop(id: string): Promise<void> { async stop({ id, title, alerts }: T.Manifest) {
const loader = this.loader.open(`Stopping...`).subscribe() const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
let content = alerts.stop || ''
try { if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
await this.api.stopPackage({ id }) content = content ? `${content}.\n\n${depMessage}` : depMessage
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
} }
defer(() =>
content
? this.dialogs
.open(TUI_CONFIRM, getOptions(content, 'Stop'))
.pipe(filter(Boolean))
: of(null),
).subscribe(async () => {
const loader = this.loader.open(`Stopping...`).subscribe()
try {
await this.api.stopPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
} }
private async doRestart(id: string): Promise<void> { async restart({ id, title }: T.Manifest) {
const loader = this.loader.open(`Restarting...`).subscribe() const packages = await getAllPackages(this.patch)
const options = getOptions(
`Services that depend on ${title} may temporarily experiences issues`,
'Restart',
)
try { defer(() =>
await this.api.restartPackage({ id }) hasCurrentDeps(id, packages)
} catch (e: any) { ? this.dialogs.open(TUI_CONFIRM, options).pipe(filter(Boolean))
this.errorService.handleError(e) : of(null),
} finally { ).subscribe(async () => {
loader.unsubscribe() const loader = this.loader.open(`Restarting...`).subscribe()
}
try {
await this.api.restartPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
} }
private alert(content: string): Promise<boolean> { private alert(content: string): Promise<boolean> {

View File

@@ -60,7 +60,7 @@ export class DepErrorService {
): PkgDependencyErrors { ): PkgDependencyErrors {
const pkg = pkgs[pkgId] const pkg = pkgs[pkgId]
if (!isInstalled(pkg)) return {} if (!pkg || !isInstalled(pkg)) return {}
return currentDeps(pkgs, pkgId).reduce( return currentDeps(pkgs, pkgId).reduce(
(innerErrors, depId): PkgDependencyErrors => ({ (innerErrors, depId): PkgDependencyErrors => ({
@@ -88,17 +88,14 @@ export class DepErrorService {
const currentDep = pkg.currentDependencies[depId] const currentDep = pkg.currentDependencies[depId]
const depManifest = dep.stateInfo.manifest const depManifest = dep.stateInfo.manifest
const expected = currentDep?.versionRange || ''
// incorrect version // incorrect version
if (!this.exver.satisfies(depManifest.version, currentDep.versionRange)) { if (!this.exver.satisfies(depManifest.version, expected)) {
if ( if (depManifest.satisfies.some(v => !this.exver.satisfies(v, expected))) {
depManifest.satisfies.some(
v => !this.exver.satisfies(v, currentDep.versionRange),
)
) {
return { return {
expected,
type: 'incorrectVersion', type: 'incorrectVersion',
expected: currentDep.versionRange,
received: depManifest.version, received: depManifest.version,
} }
} }
@@ -128,10 +125,10 @@ export class DepErrorService {
} }
// health check failure // health check failure
if (depStatus === 'running' && currentDep.kind === 'running') { if (depStatus === 'running' && currentDep?.kind === 'running') {
for (let id of currentDep.healthChecks) { for (let id of currentDep.healthChecks) {
const check = dep.status.health[id] const check = dep.status.health[id]
if (check?.result !== 'success') { if (check && check?.result !== 'success') {
return { return {
type: 'healthChecksFailed', type: 'healthChecksFailed',
check, check,
@@ -142,7 +139,7 @@ export class DepErrorService {
// transitive // transitive
const transitiveError = currentDeps(pkgs, depId).some(transitiveId => const transitiveError = currentDeps(pkgs, depId).some(transitiveId =>
Object.values(outerErrors[transitiveId]).some(err => !!err), Object.values(outerErrors[transitiveId] || {}).some(err => !!err),
) )
if (transitiveError) { if (transitiveError) {

View File

@@ -42,9 +42,12 @@ export class FormService {
const selected = valid ? value?.selection : spec.default const selected = valid ? value?.selection : spec.default
const selection = this.getUnionSelectSpec(spec, selected) const selection = this.getUnionSelectSpec(spec, selected)
const group = this.getFormGroup({ selection }) const group = this.getFormGroup({ selection })
const control = selected ? spec.variants[selected].spec : {} const control = selected ? spec.variants[selected]?.spec : {}
group.setControl('value', this.getFormGroup(control, [], value?.value)) group.setControl(
'value',
this.getFormGroup(control || {}, [], value?.value),
)
return group return group
} }
@@ -410,7 +413,12 @@ function listObjEquals(
if (!uniqueBy) { if (!uniqueBy) {
return false return false
} else if (typeof uniqueBy === 'string') { } else if (typeof uniqueBy === 'string') {
return uniqueByEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy]) const uniqueBySpec = spec.spec[uniqueBy]
return (
!!uniqueBySpec &&
uniqueByEquals(uniqueBySpec, val1[uniqueBy], val2[uniqueBy])
)
} else if ('any' in uniqueBy) { } else if ('any' in uniqueBy) {
for (let unique of uniqueBy.any) { for (let unique of uniqueBy.any) {
if (listObjEquals(unique, spec, val1, val2)) { if (listObjEquals(unique, spec, val1, val2)) {
@@ -522,8 +530,12 @@ export function convertValuesRecursive(
convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup) convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup)
} else if (valueSpec.type === 'union') { } else if (valueSpec.type === 'union') {
const formGr = group.get(key) as UntypedFormGroup const formGr = group.get(key) as UntypedFormGroup
const spec = valueSpec.variants[formGr.controls['selection'].value].spec const value = formGr.controls['selection']?.value
convertValuesRecursive(spec, formGr) const spec = !!value && valueSpec.variants[value]?.spec
if (spec) {
convertValuesRecursive(spec, formGr)
}
} else if (valueSpec.type === 'list') { } else if (valueSpec.type === 'list') {
const formArr = group.get(key) as UntypedFormArray const formArr = group.get(key) as UntypedFormArray
const { controls } = formArr const { controls } = formArr

View File

@@ -249,20 +249,23 @@ export class MarketplaceService {
flavor: string | null, flavor: string | null,
pkgInfo: GetPackageRes, pkgInfo: GetPackageRes,
): MarketplacePkg { ): MarketplacePkg {
version = const ver =
version || version ||
Object.keys(pkgInfo.best).find(v => this.exver.getFlavor(v) === flavor) || Object.keys(pkgInfo.best).find(v => this.exver.getFlavor(v) === flavor) ||
null null
const best = ver && pkgInfo.best[ver]
return !version || !pkgInfo.best[version] if (!best) {
? ({} as MarketplacePkg) return {} as MarketplacePkg
: { }
id,
version, return {
flavor, id,
...pkgInfo, flavor,
...pkgInfo.best[version], version: ver || '',
} ...pkgInfo,
...best,
}
} }
getRequestErrors$(): Observable<string[]> { getRequestErrors$(): Observable<string[]> {

View File

@@ -6,10 +6,7 @@ export function toAcmeUrl(name: string): string {
return knownACME.find(acme => acme.name === name)?.url || name return knownACME.find(acme => acme.name === name)?.url || name
} }
export const knownACME: { export const knownACME = [
name: string
url: string
}[] = [
{ {
name: `Let's Encrypt`, name: `Let's Encrypt`,
url: 'https://acme-v02.api.letsencrypt.org/directory', url: 'https://acme-v02.api.letsencrypt.org/directory',
@@ -18,4 +15,4 @@ export const knownACME: {
name: `Let's Encrypt (Staging)`, name: `Let's Encrypt (Staging)`,
url: 'https://acme-staging-v02.api.letsencrypt.org/directory', url: 'https://acme-staging-v02.api.letsencrypt.org/directory',
}, },
] ] as const

View File

@@ -13,7 +13,10 @@ export function dryUpdate(
Object.keys(pkg.currentDependencies || {}).some( Object.keys(pkg.currentDependencies || {}).some(
pkgId => pkgId === id, pkgId => pkgId === id,
) && ) &&
!exver.satisfies(version, pkg.currentDependencies[id].versionRange), !exver.satisfies(
version,
pkg.currentDependencies[id]?.versionRange || '',
),
) )
.map(pkg => getManifest(pkg).title) .map(pkg => getManifest(pkg).title)
} }

View File

@@ -47,8 +47,8 @@ export function getMenu() {
const badge = inject(BadgeService) const badge = inject(BadgeService)
return Object.keys(SYSTEM_UTILITIES).map(key => ({ return Object.keys(SYSTEM_UTILITIES).map(key => ({
name: SYSTEM_UTILITIES[key].title, name: SYSTEM_UTILITIES[key]?.title || '',
icon: SYSTEM_UTILITIES[key].icon, icon: SYSTEM_UTILITIES[key]?.icon || '',
routerLink: key, routerLink: key,
badge: toSignal(badge.getCount(key), { initialValue: 0 }), badge: toSignal(badge.getCount(key), { initialValue: 0 }),
})) }))

View File

@@ -69,7 +69,6 @@ hr {
min-height: fit-content; min-height: fit-content;
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
overflow: hidden;
} }
.g-aside { .g-aside {

View File

@@ -8,6 +8,7 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"sourceMap": true, "sourceMap": true,