mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
@@ -54,7 +54,6 @@ header {
|
||||
}
|
||||
|
||||
store-icon {
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 100%;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,6 @@ export class LogsWindowComponent {
|
||||
}
|
||||
|
||||
onBottom(entries: readonly IntersectionObserverEntry[]) {
|
||||
this.scroll = entries[entries.length - 1].isIntersecting
|
||||
this.scroll = !!entries[entries.length - 1]?.isIntersecting
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@ export function convertBytes(bytes: number): string {
|
||||
export class DurationToSecondsPipe implements PipeTransform {
|
||||
transform(duration?: string | null): number {
|
||||
if (!duration) return 0
|
||||
const [, num, , unit] =
|
||||
duration.match(/^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/) || []
|
||||
return Number(num) * unitsToSeconds[unit]
|
||||
|
||||
const regex = /^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/
|
||||
const [, num, , unit] = duration.match(regex) || []
|
||||
const multiplier = (unit && unitsToSeconds[unit]) || NaN
|
||||
|
||||
return unit ? Number(num) * multiplier : NaN
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,13 +294,3 @@ a {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class AppComponent implements OnInit {
|
||||
ngOnInit() {
|
||||
this.patch.watch$('ui').subscribe(({ name, language }) => {
|
||||
this.title.setTitle(name || 'StartOS')
|
||||
this.i18n.setLanguage(language)
|
||||
this.i18n.setLanguage(language || 'english')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,6 @@ export function appInitializer(): () => void {
|
||||
auth.init()
|
||||
localStorage.init()
|
||||
router.initialNavigation()
|
||||
i18n.setLanguage(i18n.language)
|
||||
i18n.setLanguage(i18n.language || 'english')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export class i18nService extends TuiLanguageSwitcherService {
|
||||
|
||||
readonly loading = signal(false)
|
||||
|
||||
override setLanguage(language: TuiLanguageName): void {
|
||||
override setLanguage(language: TuiLanguageName = 'english'): void {
|
||||
if (this.language === language) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<section
|
||||
class="top"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="onTop($event[0].isIntersecting)"
|
||||
(waIntersectionObservee)="onTop(!!$event[0]?.isIntersecting)"
|
||||
>
|
||||
@if (loading) {
|
||||
<tui-loader textContent="Loading logs" />
|
||||
|
||||
@@ -16,7 +16,8 @@ export class FormMultiselectComponent extends Control<
|
||||
|
||||
private readonly isDisabled = (item: string) =>
|
||||
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) =>
|
||||
!!this.spec.maxLength &&
|
||||
@@ -44,6 +45,6 @@ export class FormMultiselectComponent extends Control<
|
||||
|
||||
@tuiPure
|
||||
private memoize(value: null | readonly string[]): string[] {
|
||||
return value?.map(key => this.spec.values[key]) || []
|
||||
return value?.map(key => this.spec.values[key] || '') || []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
|
||||
|
||||
readonly disabledItemHandler = (item: string) =>
|
||||
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 {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
<tui-elastic-container class="g-form-group" formGroupName="value">
|
||||
<form-group
|
||||
class="group"
|
||||
[spec]="(union && spec.variants[union].spec) || {}"
|
||||
[spec]="(union && spec.variants[union]?.spec) || {}"
|
||||
></form-group>
|
||||
</tui-elastic-container>
|
||||
|
||||
@@ -38,11 +38,11 @@ export class FormUnionComponent implements OnChanges {
|
||||
|
||||
@tuiPure
|
||||
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(
|
||||
'value',
|
||||
this.formService.getFormGroup(
|
||||
union ? this.spec.variants[union].spec : {},
|
||||
union ? this.spec.variants[union]?.spec || {} : {},
|
||||
[],
|
||||
this.values[union],
|
||||
),
|
||||
|
||||
@@ -71,22 +71,14 @@ type ClearnetForm = {
|
||||
Make {{ isPublic() ? 'private' : 'public' }}
|
||||
</button>
|
||||
@if (clearnet().length) {
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.plus"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="add()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button tuiButton iconStart="@tui.plus" (click)="add()">Add</button>
|
||||
}
|
||||
</header>
|
||||
@if (clearnet().length) {
|
||||
<table [appTable]="['Domain', 'ACME', 'URL', '']">
|
||||
<table [appTable]="['ACME', 'URL', '']">
|
||||
@for (address of clearnet(); track $index) {
|
||||
<tr>
|
||||
<td [style.width.rem]="15">{{ address.label }}</td>
|
||||
<td>{{ address.acme | acme }}</td>
|
||||
<td [style.width.rem]="12">{{ address.acme | acme }}</td>
|
||||
<td>{{ address.url | mask }}</td>
|
||||
<td [actions]="address.url">
|
||||
<button
|
||||
|
||||
@@ -29,13 +29,14 @@ export function getAddresses(
|
||||
tor: AddressDetails[]
|
||||
} {
|
||||
const addressInfo = serviceInterface.addressInfo
|
||||
const hostnames = host.hostnameInfo[addressInfo.internalPort].filter(
|
||||
h =>
|
||||
config.isLocalhost() ||
|
||||
h.kind !== 'ip' ||
|
||||
h.hostname.kind !== 'ipv6' ||
|
||||
!h.hostname.value.startsWith('fe80::'),
|
||||
)
|
||||
const hostnames =
|
||||
host.hostnameInfo[addressInfo.internalPort]?.filter(
|
||||
h =>
|
||||
config.isLocalhost() ||
|
||||
h.kind !== 'ip' ||
|
||||
h.hostname.kind !== 'ipv6' ||
|
||||
!h.hostname.value.startsWith('fe80::'),
|
||||
) || []
|
||||
|
||||
if (config.isLocalhost()) {
|
||||
const local = hostnames.find(
|
||||
@@ -67,11 +68,10 @@ export function getAddresses(
|
||||
addresses.forEach(url => {
|
||||
if (h.kind === 'onion') {
|
||||
tor.push({
|
||||
label: `Tor${
|
||||
label:
|
||||
addresses.length > 1
|
||||
? ` (${new URL(url).protocol.replace(':', '').toUpperCase()})`
|
||||
: ''
|
||||
}`,
|
||||
? new URL(url).protocol.replace(':', '').toUpperCase()
|
||||
: '',
|
||||
url,
|
||||
})
|
||||
} else {
|
||||
@@ -79,14 +79,10 @@ export function getAddresses(
|
||||
|
||||
if (h.public) {
|
||||
clearnet.push({
|
||||
label:
|
||||
hostnameKind == 'domain'
|
||||
? 'Domain'
|
||||
: `${h.networkInterfaceId} (${hostnameKind})`,
|
||||
url,
|
||||
acme:
|
||||
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
|
||||
})
|
||||
} else {
|
||||
@@ -128,7 +124,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
|
||||
}
|
||||
|
||||
export type AddressDetails = {
|
||||
label: string
|
||||
label?: string
|
||||
url: string
|
||||
acme?: string | null
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { MaskPipe } from './mask.pipe'
|
||||
<table [appTable]="['Network Interface', 'URL', '']">
|
||||
@for (address of local(); track $index) {
|
||||
<tr>
|
||||
<td [style.width.rem]="15">{{ address.label }}</td>
|
||||
<td [style.width.rem]="12">{{ address.label }}</td>
|
||||
<td>{{ address.url | mask }}</td>
|
||||
<td [actions]="address.url"></td>
|
||||
</tr>
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
TuiLink,
|
||||
TuiOption,
|
||||
} 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 {
|
||||
FormComponent,
|
||||
@@ -69,8 +74,12 @@ type OnionForm = {
|
||||
<table [appTable]="['Protocol', 'URL', '']">
|
||||
@for (address of tor(); track $index) {
|
||||
<tr>
|
||||
<td [style.width.rem]="15">{{ address.label }}</td>
|
||||
<td>{{ address.url | mask }}</td>
|
||||
<td [style.width.rem]="12">{{ address.label }}</td>
|
||||
<td>
|
||||
<div [tuiFluidTypography]="[0.625, 0.8125]" tuiFade>
|
||||
{{ address.url | mask }}
|
||||
</div>
|
||||
</td>
|
||||
<td [actions]="address.url">
|
||||
<button
|
||||
tuiButton
|
||||
@@ -99,6 +108,12 @@ type OnionForm = {
|
||||
</app-placeholder>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
[tuiFade] {
|
||||
white-space: nowrap;
|
||||
max-width: 30rem;
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
imports: [
|
||||
TuiButton,
|
||||
@@ -111,6 +126,8 @@ type OnionForm = {
|
||||
PlaceholderComponent,
|
||||
MaskPipe,
|
||||
InterfaceActionsComponent,
|
||||
TuiFade,
|
||||
TuiFluidTypography,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ export class LogsFetchDirective {
|
||||
|
||||
@Output()
|
||||
readonly logsFetch = defer(() => this.observer).pipe(
|
||||
filter(([{ isIntersecting }]) => isIntersecting && !this.component.scroll),
|
||||
filter(([entry]) => !!entry?.isIntersecting && !this.component.scroll),
|
||||
switchMap(() =>
|
||||
from(
|
||||
this.component.fetchLogs({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section
|
||||
class="top"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="onLoading($event[0].isIntersecting)"
|
||||
(waIntersectionObservee)="onLoading(!!$event[0]?.isIntersecting)"
|
||||
(logsFetch)="onPrevious($event)"
|
||||
>
|
||||
@if (loading) {
|
||||
@@ -41,7 +41,7 @@
|
||||
class="bottom"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="
|
||||
setScroll($event[$event.length - 1].isIntersecting)
|
||||
setScroll(!!$event[$event.length - 1]?.isIntersecting)
|
||||
"
|
||||
></section>
|
||||
</tui-scrollbar>
|
||||
|
||||
@@ -86,7 +86,7 @@ function toNavigationItem(
|
||||
const item = SYSTEM_UTILITIES[id]
|
||||
const routerLink = toRouterLink(id)
|
||||
|
||||
return SYSTEM_UTILITIES[id]
|
||||
return item
|
||||
? {
|
||||
icon: item.icon,
|
||||
title: item.title,
|
||||
@@ -94,7 +94,7 @@ function toNavigationItem(
|
||||
}
|
||||
: {
|
||||
icon: packages[id]?.icon,
|
||||
title: getManifest(packages[id]).title,
|
||||
title: getManifest(packages[id]!).title,
|
||||
routerLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,7 @@ import {
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiLink,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCardMedium } from '@taiga-ui/layout'
|
||||
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
@@ -37,7 +31,7 @@ interface Log {
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{{ logs[key].title }}
|
||||
{{ logs[key]?.title }}
|
||||
} @else {
|
||||
Logs
|
||||
}
|
||||
@@ -55,9 +49,9 @@ interface Log {
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{{ logs[key].title }}
|
||||
{{ logs[key]?.title }}
|
||||
</strong>
|
||||
<p tuiSubtitle>{{ logs[key].subtitle }}</p>
|
||||
<p tuiSubtitle>{{ logs[key]?.subtitle }}</p>
|
||||
</header>
|
||||
@for (log of logs | keyvalue; track $index) {
|
||||
@if (log.key === current()) {
|
||||
@@ -169,7 +163,6 @@ interface Log {
|
||||
TuiCardMedium,
|
||||
TuiIcon,
|
||||
TuiAppearance,
|
||||
TuiLink,
|
||||
TuiButton,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
selector: 'tr[actionRequest]',
|
||||
template: `
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
@@ -35,7 +35,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
</td>
|
||||
<td>
|
||||
<button tuiButton (click)="handle()">
|
||||
{{ pkg().actions[actionRequest().actionId].name }}
|
||||
{{ pkg()?.actions?.[actionRequest().actionId]?.name }}
|
||||
</button>
|
||||
</td>
|
||||
`,
|
||||
@@ -79,19 +79,27 @@ export class ServiceActionRequestComponent {
|
||||
readonly services = input.required<Record<string, PackageDataEntry>>()
|
||||
|
||||
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() {
|
||||
const title = this.title()
|
||||
const pkg = this.pkg()
|
||||
const metadata = pkg?.actions[this.actionRequest().actionId]
|
||||
|
||||
if (!title || !pkg || !metadata) {
|
||||
return
|
||||
}
|
||||
|
||||
this.actionService.present({
|
||||
pkgInfo: {
|
||||
id: this.actionRequest().packageId,
|
||||
title: this.title(),
|
||||
mainStatus: this.pkg().status.main,
|
||||
icon: this.pkg().icon,
|
||||
title,
|
||||
mainStatus: pkg.status.main,
|
||||
icon: pkg.icon,
|
||||
},
|
||||
actionInfo: {
|
||||
id: this.actionRequest().actionId,
|
||||
metadata: this.pkg().actions[this.actionRequest().actionId],
|
||||
metadata,
|
||||
},
|
||||
requestInfo: this.actionRequest(),
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ServiceActionRequestComponent } from './action-request.component'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
min-height: 12rem;
|
||||
grid-column: span 6;
|
||||
}
|
||||
`,
|
||||
@@ -50,7 +51,13 @@ export class ServiceActionRequestsComponent {
|
||||
|
||||
readonly requests = computed(() =>
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
@Component({
|
||||
selector: 'service-controls',
|
||||
template: `
|
||||
@if (['running', 'starting', 'restarting'].includes(status)) {
|
||||
@if (status && ['running', 'starting', 'restarting'].includes(status)) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
appearance="primary-destructive"
|
||||
iconStart="@tui.square"
|
||||
(click)="controls.stop(manifest())"
|
||||
>
|
||||
@@ -41,7 +41,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.play"
|
||||
(click)="controls.start(manifest(), !!hasUnmet)"
|
||||
(click)="controls.start(manifest(), !!hasUnmet())"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
@@ -50,11 +50,12 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
max-inline-size: 100%;
|
||||
margin-block-start: 1rem;
|
||||
|
||||
&: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) {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
@@ -85,12 +91,13 @@ export class ServiceControlsComponent {
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
@Input({ required: true })
|
||||
status!: PrimaryStatus
|
||||
status?: PrimaryStatus
|
||||
|
||||
readonly manifest = computed(() => getManifest(this.pkg))
|
||||
|
||||
readonly controls = inject(ControlsService)
|
||||
|
||||
// @TODO Alex observable in signal?
|
||||
readonly hasUnmet = computed(() =>
|
||||
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
|
||||
map(errors =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
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'
|
||||
|
||||
@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>
|
||||
<span tuiTitle>
|
||||
{{ 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>
|
||||
<tui-icon icon="@tui.arrow-right" />
|
||||
@@ -30,6 +36,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
min-height: 12rem;
|
||||
grid-column: span 3;
|
||||
}
|
||||
`,
|
||||
@@ -52,4 +59,32 @@ export class ServiceDependenciesComponent {
|
||||
|
||||
@Input({ required: true })
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ServiceHealthCheckComponent } from './health-check.component'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
min-height: 12rem;
|
||||
grid-column: span 3;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ServiceInterfacesComponent {
|
||||
|
||||
return {
|
||||
...value,
|
||||
public: !!host?.bindings[value.addressInfo.internalPort].net.public,
|
||||
public: !!host?.bindings[value.addressInfo.internalPort]?.net.public,
|
||||
addresses: host ? getAddresses(value, host, this.config) : {},
|
||||
routerLink: `./interface/${id}`,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
<small>See below</small>
|
||||
}
|
||||
|
||||
@if (rendering.showDots) {
|
||||
@if (rendering?.showDots) {
|
||||
<span class="loading-dots"></span>
|
||||
}
|
||||
</h3>
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
font: var(--tui-font-heading-4);
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div {
|
||||
@@ -76,6 +77,10 @@ import {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
small {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -89,7 +94,7 @@ import {
|
||||
})
|
||||
export class ServiceStatusComponent {
|
||||
@Input({ required: true })
|
||||
status!: PrimaryStatus
|
||||
status?: PrimaryStatus
|
||||
|
||||
@Input()
|
||||
installingInfo?: InstallingInfo
|
||||
@@ -98,13 +103,13 @@ export class ServiceStatusComponent {
|
||||
connected = false
|
||||
|
||||
get text() {
|
||||
return this.connected ? this.rendering.display : 'Unknown'
|
||||
return this.connected ? this.rendering?.display : 'Unknown'
|
||||
}
|
||||
|
||||
get class(): string | null {
|
||||
if (!this.connected) return null
|
||||
|
||||
switch (this.rendering.color) {
|
||||
switch (this.rendering?.color) {
|
||||
case 'danger':
|
||||
return 'g-negative'
|
||||
case 'warning':
|
||||
@@ -119,7 +124,7 @@ export class ServiceStatusComponent {
|
||||
}
|
||||
|
||||
get rendering() {
|
||||
return PrimaryRendering[this.status]
|
||||
return this.status && PrimaryRendering[this.status]
|
||||
}
|
||||
|
||||
getText(progress: T.Progress): string {
|
||||
|
||||
@@ -30,15 +30,6 @@ const RUNNING = ['running', 'starting', 'restarting']
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.rotate-cw"
|
||||
[disabled]="status().primary !== 'running'"
|
||||
(click)="controls.restart(manifest())"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
*tuiLet="hasUnmet() | async as hasUnmet"
|
||||
|
||||
@@ -181,7 +181,7 @@ export class ActionInputModal {
|
||||
.filter(
|
||||
id =>
|
||||
id !== this.pkgInfo.id &&
|
||||
Object.values(packages[id].requestedActions).some(
|
||||
Object.values(packages[id]!.requestedActions).some(
|
||||
({ request, active }) =>
|
||||
!active &&
|
||||
request.severity === 'critical' &&
|
||||
@@ -201,7 +201,7 @@ export class ActionInputModal {
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${breakages.map(
|
||||
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
|
||||
id => `<li><b>${getManifest(packages[id]!).title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiConfirmData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
|
||||
@@ -14,39 +14,44 @@ import { map } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import {
|
||||
AdditionalItem,
|
||||
FALLBACK_URL,
|
||||
ServiceAdditionalItemComponent,
|
||||
} from '../components/additional-item.component'
|
||||
import { KeyValuePipe } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section class="g-card">
|
||||
@for (additional of items(); track $index) {
|
||||
@if (additional.description.startsWith('http')) {
|
||||
<a tuiCell [additionalItem]="additional"></a>
|
||||
} @else {
|
||||
<button
|
||||
tuiCell
|
||||
[style.pointer-events]="!additional.icon ? 'none' : null"
|
||||
[additionalItem]="additional"
|
||||
(click)="additional.action?.()"
|
||||
></button>
|
||||
@for (group of items() | keyvalue; track $index) {
|
||||
<section class="g-card">
|
||||
<header>{{ group.key }}</header>
|
||||
@for (additional of group.value; track $index) {
|
||||
@if (additional.description.startsWith('http')) {
|
||||
<a tuiCell [additionalItem]="additional"></a>
|
||||
} @else {
|
||||
<button
|
||||
tuiCell
|
||||
[style.pointer-events]="!additional.icon ? 'none' : null"
|
||||
[additionalItem]="additional"
|
||||
(click)="additional.action?.()"
|
||||
></button>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
section {
|
||||
max-width: 42rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 36rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-subpage' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
host: { class: 'g-subpage' },
|
||||
imports: [ServiceAdditionalItemComponent, TuiCell],
|
||||
imports: [ServiceAdditionalItemComponent, TuiCell, KeyValuePipe],
|
||||
})
|
||||
export default class ServiceAboutRoute {
|
||||
private readonly copyService = inject(CopyService)
|
||||
@@ -55,56 +60,62 @@ export default class ServiceAboutRoute {
|
||||
{ label: 'License', size: 'l' },
|
||||
)
|
||||
|
||||
readonly items = toSignal(
|
||||
readonly items = toSignal<Record<string, AdditionalItem[]>>(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData', getPkgId())
|
||||
.pipe(
|
||||
map(pkg => {
|
||||
const manifest = getManifest(pkg)
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Version',
|
||||
description: manifest.version,
|
||||
},
|
||||
{
|
||||
name: 'Git Hash',
|
||||
description: manifest.gitHash || 'Unknown',
|
||||
icon: manifest.gitHash ? '@tui.copy' : '',
|
||||
action: () =>
|
||||
manifest.gitHash && this.copyService.copy(manifest.gitHash),
|
||||
},
|
||||
{
|
||||
name: 'License',
|
||||
description: manifest.license,
|
||||
icon: '@tui.chevron-right',
|
||||
action: () => this.markdown.subscribe(),
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
description: manifest.marketingSite || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Donation Link',
|
||||
description: manifest.donationUrl || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Source Repository',
|
||||
description: manifest.upstreamRepo,
|
||||
},
|
||||
{
|
||||
name: 'Support Site',
|
||||
description: manifest.supportSite || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Registry',
|
||||
description: pkg.registry || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Binary Source',
|
||||
description: manifest.wrapperRepo,
|
||||
},
|
||||
]
|
||||
return {
|
||||
General: [
|
||||
{
|
||||
name: 'Version',
|
||||
description: manifest.version,
|
||||
icon: '@tui.copy',
|
||||
action: () => this.copyService.copy(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(),
|
||||
},
|
||||
],
|
||||
Links: [
|
||||
{
|
||||
name: 'Installed From',
|
||||
description: pkg.registry || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Service Repository',
|
||||
description: manifest.upstreamRepo,
|
||||
},
|
||||
{
|
||||
name: 'Package Repository',
|
||||
description: manifest.wrapperRepo,
|
||||
},
|
||||
{
|
||||
name: 'Marketing Site',
|
||||
description: manifest.marketingSite || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Support Site',
|
||||
description: manifest.supportSite || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Donation Link',
|
||||
description: manifest.donationUrl || FALLBACK_URL,
|
||||
},
|
||||
],
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,24 +12,11 @@ import { StandardActionsService } from 'src/app/services/standard-actions.servic
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import { ServiceActionComponent } from '../components/action.component'
|
||||
|
||||
const OTHER = 'Other Custom Actions'
|
||||
const OTHER = 'Custom Actions'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@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) {
|
||||
@if (group.value.length) {
|
||||
<section class="g-card">
|
||||
@@ -46,6 +33,19 @@ const OTHER = 'Other Custom Actions'
|
||||
</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: `
|
||||
@@ -80,8 +80,8 @@ export default class ServiceActionsRoute {
|
||||
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
|
||||
>(
|
||||
(acc, [id]) => {
|
||||
const action = { id, ...pkg.actions[id] }
|
||||
const group = pkg.actions[id].group || OTHER
|
||||
const action = { id, ...pkg.actions[id]! }
|
||||
const group = pkg.actions[id]?.group || OTHER
|
||||
const current = acc[group] || []
|
||||
|
||||
return { ...acc, [group]: current.concat(action) }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -11,7 +10,7 @@ import { RouterLink } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { TuiItem } from '@taiga-ui/cdk'
|
||||
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 { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
||||
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||
@@ -59,8 +58,6 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
TuiBreadcrumbs,
|
||||
TuiItem,
|
||||
TuiLink,
|
||||
TuiBadge,
|
||||
NgTemplateOutlet,
|
||||
InterfaceStatusComponent,
|
||||
],
|
||||
})
|
||||
@@ -84,11 +81,16 @@ export default class ServiceInterfaceRoute {
|
||||
|
||||
const { serviceInterfaces, hosts } = pkg
|
||||
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 {
|
||||
...item,
|
||||
public: host.bindings[item.addressInfo.internalPort].net.public,
|
||||
public: !!host?.bindings[item.addressInfo.internalPort]?.net.public,
|
||||
addresses: getAddresses(item, host, this.config),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,9 +10,10 @@ import { ActivatedRoute } from '@angular/router'
|
||||
import { isEmptyObject } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
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 { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
@@ -31,31 +32,36 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
template: `
|
||||
<service-status
|
||||
[connected]="!!connected()"
|
||||
[installingInfo]="pkg().stateInfo.installingInfo"
|
||||
[installingInfo]="pkg()?.stateInfo?.installingInfo"
|
||||
[status]="status()"
|
||||
>
|
||||
@if ($any(pkg().status).started; as started) {
|
||||
@if ($any(pkg()?.status)?.started; as started) {
|
||||
<p class="g-secondary" [appUptime]="started"></p>
|
||||
}
|
||||
@if (installed() && connected()) {
|
||||
<service-controls [pkg]="pkg()" [status]="status()" />
|
||||
@if (installed() && connected() && pkg(); as pkg) {
|
||||
<service-controls [pkg]="pkg" [status]="status()" />
|
||||
}
|
||||
</service-status>
|
||||
|
||||
@if (installed()) {
|
||||
@if (pkg().status.main === 'error') {
|
||||
<service-error [pkg]="pkg()" />
|
||||
@if (installed() && pkg(); as pkg) {
|
||||
@if (pkg.status.main === 'error') {
|
||||
<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-action-requests [pkg]="pkg()" [services]="services()" />
|
||||
<service-action-requests [pkg]="pkg" [services]="services() || {}" />
|
||||
}
|
||||
|
||||
@if (installing()) {
|
||||
@if (installing() && pkg(); as pkg) {
|
||||
@for (
|
||||
item of pkg().stateInfo.installingInfo?.progress?.phases;
|
||||
item of pkg.stateInfo.installingInfo?.progress?.phases;
|
||||
track $index
|
||||
) {
|
||||
<p [progress]="item.progress">{{ item.name }}</p>
|
||||
@@ -100,6 +106,7 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
],
|
||||
})
|
||||
export class ServiceRoute {
|
||||
private readonly errorService = inject(DepErrorService)
|
||||
protected readonly connected = toSignal(inject(ConnectionService))
|
||||
|
||||
protected readonly id = toSignal(
|
||||
@@ -111,10 +118,14 @@ export class ServiceRoute {
|
||||
{ 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 health = computed(() =>
|
||||
this.pkg() ? toHealthCheck(this.pkg().status) : [],
|
||||
protected readonly health = computed((pkg = this.pkg()) =>
|
||||
pkg ? toHealthCheck(pkg.status) : [],
|
||||
)
|
||||
|
||||
protected readonly status = computed((pkg = this.pkg()) =>
|
||||
|
||||
@@ -120,9 +120,9 @@ export default class SystemAcmeComponent {
|
||||
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
||||
map(acme =>
|
||||
Object.keys(acme).map(url => {
|
||||
const contact = acme[url].contact.map(mailto =>
|
||||
mailto.replace('mailto:', ''),
|
||||
)
|
||||
const contact =
|
||||
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
|
||||
[]
|
||||
return {
|
||||
url,
|
||||
contact,
|
||||
|
||||
@@ -136,7 +136,7 @@ export class BackupsBackupComponent {
|
||||
// existing backup
|
||||
} else {
|
||||
try {
|
||||
argon2.verify(entry.startOs[id].passwordHash!, password)
|
||||
argon2.verify(entry.startOs[id]?.passwordHash!, password)
|
||||
await this.createBackup(password)
|
||||
} catch {
|
||||
this.oldPassword(password)
|
||||
@@ -156,7 +156,7 @@ export class BackupsBackupComponent {
|
||||
|
||||
private async oldPassword(password: string) {
|
||||
const { id } = await getServerInfo(this.patch)
|
||||
const { passwordHash } = this.context.data.entry.startOs[id]
|
||||
const { passwordHash = '' } = this.context.data.entry.startOs[id] || {}
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PROMPT, PASSWORD_OPTIONS)
|
||||
|
||||
@@ -68,10 +68,11 @@ export class BackupService {
|
||||
}
|
||||
|
||||
hasThisBackup({ startOs }: BackupTarget, id: string): boolean {
|
||||
const item = startOs[id]
|
||||
|
||||
return (
|
||||
startOs[id] &&
|
||||
Version.parse(startOs[id].version).compare(Version.parse('0.3.6')) !==
|
||||
'less'
|
||||
!!item &&
|
||||
Version.parse(item.version).compare(Version.parse('0.3.6')) !== 'less'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import { ActivatedRoute } from '@angular/router'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
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 { TuiCell } from '@taiga-ui/layout'
|
||||
import { filter } from 'rxjs'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
@@ -232,7 +231,7 @@ export class BackupNetworkComponent {
|
||||
...value,
|
||||
})
|
||||
|
||||
target.entry = Object.values(res)[0]
|
||||
target.entry = Object.values(res)[0]!
|
||||
this.service.cifs.update(cifs => [...cifs])
|
||||
return true
|
||||
} catch (e: any) {
|
||||
@@ -273,7 +272,13 @@ export class BackupNetworkComponent {
|
||||
.subscribe()
|
||||
|
||||
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 added = { id, entry, hasAnyBackup }
|
||||
this.service.cifs.update(cifs => [added, ...cifs])
|
||||
|
||||
@@ -91,12 +91,12 @@ export class BackupsRecoverComponent {
|
||||
|
||||
return Object.keys(backups)
|
||||
.map(id => ({
|
||||
...backups[id],
|
||||
...backups[id]!,
|
||||
id,
|
||||
installed: !!packageData[id],
|
||||
checked: false,
|
||||
newerOs:
|
||||
Version.parse(backups[id].osVersion).compare(
|
||||
Version.parse(backups[id]?.osVersion || '').compare(
|
||||
Version.parse(this.config.version),
|
||||
) === 'greater',
|
||||
}))
|
||||
|
||||
@@ -142,8 +142,8 @@ export class SnekComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
handleTouchStart(evt: TouchEvent) {
|
||||
const firstTouch = this.getTouches(evt)[0]
|
||||
this.xDown = firstTouch.clientX
|
||||
this.yDown = firstTouch.clientY
|
||||
this.xDown = firstTouch?.clientX
|
||||
this.yDown = firstTouch?.clientY
|
||||
}
|
||||
|
||||
handleTouchMove(evt: TouchEvent) {
|
||||
@@ -151,8 +151,8 @@ export class SnekComponent implements AfterViewInit, OnDestroy {
|
||||
return
|
||||
}
|
||||
|
||||
var xUp = evt.touches[0].clientX
|
||||
var yUp = evt.touches[0].clientY
|
||||
var xUp = evt.touches[0]?.clientX || 0
|
||||
var yUp = evt.touches[0]?.clientY || 0
|
||||
|
||||
var xDiff = this.xDown - xUp
|
||||
var yDiff = this.yDown - yUp
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EOSService } from 'src/app/services/eos.service'
|
||||
|
||||
@Component({
|
||||
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">
|
||||
Release Notes
|
||||
</h3>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default class StartOsUiComponent {
|
||||
.pipe(
|
||||
map(host => ({
|
||||
...iface,
|
||||
public: host.bindings[iface.addressInfo.internalPort].net.public,
|
||||
public: !!host.bindings[iface.addressInfo.internalPort]?.net.public,
|
||||
addresses: getAddresses(iface, host, this.config),
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -70,7 +70,13 @@ export default class SystemSessionsComponent {
|
||||
private readonly sessions$ = from(this.api.getSessions({}))
|
||||
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(
|
||||
this.local$,
|
||||
this.sessions$.pipe(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
StoreIconComponentModule,
|
||||
StoreIdentity,
|
||||
} from '@start9labs/marketplace'
|
||||
import { TuiTable } from '@taiga-ui/addon-table'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiNotification, TuiTitle } from '@taiga-ui/core'
|
||||
import {
|
||||
@@ -88,7 +87,10 @@ interface UpdatesData {
|
||||
</aside>
|
||||
<section class="g-subpage">
|
||||
@if (data()?.errors?.includes(current()?.url || '')) {
|
||||
<tui-notification appearance="negative">
|
||||
<tui-notification
|
||||
appearance="negative"
|
||||
[style.margin-block-end.rem]="1"
|
||||
>
|
||||
Request Failed
|
||||
</tui-notification>
|
||||
}
|
||||
@@ -222,7 +224,9 @@ export default class UpdatesComponent {
|
||||
combineLatest({
|
||||
hosts: this.marketplaceService
|
||||
.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$,
|
||||
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData')
|
||||
|
||||
@@ -501,9 +501,9 @@ export class MockApiService extends ApiService {
|
||||
): Promise<GetPackageRes> {
|
||||
await pauseFor(2000)
|
||||
if (!versionRange || versionRange === '=*') {
|
||||
return Mock.RegistryPackages[id]
|
||||
return Mock.RegistryPackages[id]!
|
||||
} else {
|
||||
return Mock.OtherPackageVersions[id][versionRange]
|
||||
return Mock.OtherPackageVersions[id]![versionRange]!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,7 +870,7 @@ export class MockApiService extends ApiService {
|
||||
|
||||
this.mockRevision([
|
||||
{
|
||||
...appPatch[0],
|
||||
...appPatch[0]!,
|
||||
value: 'stopped',
|
||||
},
|
||||
])
|
||||
@@ -1055,18 +1055,18 @@ export class MockApiService extends ApiService {
|
||||
op: PatchOp.ADD,
|
||||
path: `/packageData/${params.id}`,
|
||||
value: {
|
||||
...Mock.LocalPkgs[params.id],
|
||||
...Mock.LocalPkgs[params.id]!,
|
||||
stateInfo: {
|
||||
// if installing
|
||||
// state: 'installing',
|
||||
|
||||
// if updating
|
||||
state: 'updating',
|
||||
manifest: mockPatchData.packageData[params.id].stateInfo.manifest!,
|
||||
manifest: mockPatchData.packageData[params.id]?.stateInfo.manifest!,
|
||||
|
||||
// both
|
||||
installingInfo: {
|
||||
newManifest: Mock.LocalPkgs[params.id].stateInfo.manifest,
|
||||
newManifest: Mock.LocalPkgs[params.id]?.stateInfo.manifest!,
|
||||
progress: PROGRESS,
|
||||
},
|
||||
},
|
||||
@@ -1123,11 +1123,11 @@ export class MockApiService extends ApiService {
|
||||
op: PatchOp.ADD,
|
||||
path: `/packageData/${id}`,
|
||||
value: {
|
||||
...Mock.LocalPkgs[id],
|
||||
...Mock.LocalPkgs[id]!,
|
||||
stateInfo: {
|
||||
state: 'restoring',
|
||||
installingInfo: {
|
||||
newManifest: Mock.LocalPkgs[id].stateInfo.manifest!,
|
||||
newManifest: Mock.LocalPkgs[id]?.stateInfo.manifest!,
|
||||
progress: PROGRESS,
|
||||
},
|
||||
},
|
||||
@@ -1640,7 +1640,7 @@ export class MockApiService extends ApiService {
|
||||
) {
|
||||
await pauseFor(2000)
|
||||
|
||||
progress.phases[i].progress = true
|
||||
progress.phases[i]!.progress = true
|
||||
|
||||
if (
|
||||
progress.overall &&
|
||||
@@ -1671,7 +1671,7 @@ export class MockApiService extends ApiService {
|
||||
if (phase.progress.done === phase.progress.total) {
|
||||
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`,
|
||||
value: {
|
||||
state: 'installed',
|
||||
manifest: Mock.LocalPkgs[id].stateInfo.manifest,
|
||||
manifest: Mock.LocalPkgs[id]?.stateInfo.manifest!,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -47,7 +47,8 @@ export class BadgeService {
|
||||
|
||||
return (
|
||||
!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] &&
|
||||
this.exver.compareExver(
|
||||
version,
|
||||
getManifest(local[id]).version,
|
||||
getManifest(local[id]!).version,
|
||||
) === 1
|
||||
? result.add(id)
|
||||
: result,
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ConfigService {
|
||||
(this.hostname.startsWith('192.168.') ||
|
||||
this.hostname.startsWith('10.') ||
|
||||
(this.hostname.startsWith('172.') &&
|
||||
!![this.hostname.split('.').map(Number)[1]].filter(
|
||||
!![this.hostname.split('.').map(Number)[1] || NaN].filter(
|
||||
n => n >= 16 && n < 32,
|
||||
).length))
|
||||
}
|
||||
@@ -107,13 +107,14 @@ export class ConfigService {
|
||||
if (!host) return ''
|
||||
|
||||
let hostnameInfo = host.hostnameInfo[ui.addressInfo.internalPort]
|
||||
hostnameInfo = hostnameInfo.filter(
|
||||
h =>
|
||||
this.isLocalhost() ||
|
||||
h.kind !== 'ip' ||
|
||||
h.hostname.kind !== 'ipv6' ||
|
||||
!h.hostname.value.startsWith('fe80::'),
|
||||
)
|
||||
hostnameInfo =
|
||||
hostnameInfo?.filter(
|
||||
h =>
|
||||
this.isLocalhost() ||
|
||||
h.kind !== 'ip' ||
|
||||
h.hostname.kind !== 'ipv6' ||
|
||||
!h.hostname.value.startsWith('fe80::'),
|
||||
) || []
|
||||
if (this.isLocalhost()) {
|
||||
const local = hostnameInfo.find(
|
||||
h => h.kind === 'ip' && h.hostname.kind === 'local',
|
||||
|
||||
@@ -2,9 +2,16 @@ import { inject, Injectable } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
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 { 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 { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getAllPackages } from 'src/app/utils/get-package-data'
|
||||
@@ -20,53 +27,16 @@ export class ControlsService {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
async start(manifest: T.Manifest, unmet: boolean): Promise<void> {
|
||||
const deps = `${manifest.title} has unmet dependencies. It will not work as expected.`
|
||||
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
|
||||
const deps = `${title} has unmet dependencies. It will not work as expected.`
|
||||
|
||||
if (
|
||||
(!unmet || (await this.alert(deps))) &&
|
||||
(!manifest.alerts.start || (await this.alert(manifest.alerts.start)))
|
||||
(unmet && !(await this.alert(deps))) ||
|
||||
(alerts.start && !(await this.alert(alerts.start)))
|
||||
) {
|
||||
this.doStart(manifest.id)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
try {
|
||||
@@ -78,28 +48,55 @@ export class ControlsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async doStop(id: string): Promise<void> {
|
||||
const loader = this.loader.open(`Stopping...`).subscribe()
|
||||
async stop({ id, title, alerts }: T.Manifest) {
|
||||
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
|
||||
let content = alerts.stop || ''
|
||||
|
||||
try {
|
||||
await this.api.stopPackage({ id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||
}
|
||||
|
||||
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> {
|
||||
const loader = this.loader.open(`Restarting...`).subscribe()
|
||||
async restart({ id, title }: T.Manifest) {
|
||||
const packages = await getAllPackages(this.patch)
|
||||
const options = getOptions(
|
||||
`Services that depend on ${title} may temporarily experiences issues`,
|
||||
'Restart',
|
||||
)
|
||||
|
||||
try {
|
||||
await this.api.restartPackage({ id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
defer(() =>
|
||||
hasCurrentDeps(id, packages)
|
||||
? this.dialogs.open(TUI_CONFIRM, options).pipe(filter(Boolean))
|
||||
: of(null),
|
||||
).subscribe(async () => {
|
||||
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> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export class DepErrorService {
|
||||
): PkgDependencyErrors {
|
||||
const pkg = pkgs[pkgId]
|
||||
|
||||
if (!isInstalled(pkg)) return {}
|
||||
if (!pkg || !isInstalled(pkg)) return {}
|
||||
|
||||
return currentDeps(pkgs, pkgId).reduce(
|
||||
(innerErrors, depId): PkgDependencyErrors => ({
|
||||
@@ -88,17 +88,14 @@ export class DepErrorService {
|
||||
|
||||
const currentDep = pkg.currentDependencies[depId]
|
||||
const depManifest = dep.stateInfo.manifest
|
||||
const expected = currentDep?.versionRange || ''
|
||||
|
||||
// incorrect version
|
||||
if (!this.exver.satisfies(depManifest.version, currentDep.versionRange)) {
|
||||
if (
|
||||
depManifest.satisfies.some(
|
||||
v => !this.exver.satisfies(v, currentDep.versionRange),
|
||||
)
|
||||
) {
|
||||
if (!this.exver.satisfies(depManifest.version, expected)) {
|
||||
if (depManifest.satisfies.some(v => !this.exver.satisfies(v, expected))) {
|
||||
return {
|
||||
expected,
|
||||
type: 'incorrectVersion',
|
||||
expected: currentDep.versionRange,
|
||||
received: depManifest.version,
|
||||
}
|
||||
}
|
||||
@@ -128,10 +125,10 @@ export class DepErrorService {
|
||||
}
|
||||
|
||||
// health check failure
|
||||
if (depStatus === 'running' && currentDep.kind === 'running') {
|
||||
if (depStatus === 'running' && currentDep?.kind === 'running') {
|
||||
for (let id of currentDep.healthChecks) {
|
||||
const check = dep.status.health[id]
|
||||
if (check?.result !== 'success') {
|
||||
if (check && check?.result !== 'success') {
|
||||
return {
|
||||
type: 'healthChecksFailed',
|
||||
check,
|
||||
@@ -142,7 +139,7 @@ export class DepErrorService {
|
||||
|
||||
// transitive
|
||||
const transitiveError = currentDeps(pkgs, depId).some(transitiveId =>
|
||||
Object.values(outerErrors[transitiveId]).some(err => !!err),
|
||||
Object.values(outerErrors[transitiveId] || {}).some(err => !!err),
|
||||
)
|
||||
|
||||
if (transitiveError) {
|
||||
|
||||
@@ -42,9 +42,12 @@ export class FormService {
|
||||
const selected = valid ? value?.selection : spec.default
|
||||
const selection = this.getUnionSelectSpec(spec, selected)
|
||||
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
|
||||
}
|
||||
@@ -410,7 +413,12 @@ function listObjEquals(
|
||||
if (!uniqueBy) {
|
||||
return false
|
||||
} 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) {
|
||||
for (let unique of uniqueBy.any) {
|
||||
if (listObjEquals(unique, spec, val1, val2)) {
|
||||
@@ -522,8 +530,12 @@ export function convertValuesRecursive(
|
||||
convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup)
|
||||
} else if (valueSpec.type === 'union') {
|
||||
const formGr = group.get(key) as UntypedFormGroup
|
||||
const spec = valueSpec.variants[formGr.controls['selection'].value].spec
|
||||
convertValuesRecursive(spec, formGr)
|
||||
const value = formGr.controls['selection']?.value
|
||||
const spec = !!value && valueSpec.variants[value]?.spec
|
||||
|
||||
if (spec) {
|
||||
convertValuesRecursive(spec, formGr)
|
||||
}
|
||||
} else if (valueSpec.type === 'list') {
|
||||
const formArr = group.get(key) as UntypedFormArray
|
||||
const { controls } = formArr
|
||||
|
||||
@@ -249,20 +249,23 @@ export class MarketplaceService {
|
||||
flavor: string | null,
|
||||
pkgInfo: GetPackageRes,
|
||||
): MarketplacePkg {
|
||||
version =
|
||||
const ver =
|
||||
version ||
|
||||
Object.keys(pkgInfo.best).find(v => this.exver.getFlavor(v) === flavor) ||
|
||||
null
|
||||
const best = ver && pkgInfo.best[ver]
|
||||
|
||||
return !version || !pkgInfo.best[version]
|
||||
? ({} as MarketplacePkg)
|
||||
: {
|
||||
id,
|
||||
version,
|
||||
flavor,
|
||||
...pkgInfo,
|
||||
...pkgInfo.best[version],
|
||||
}
|
||||
if (!best) {
|
||||
return {} as MarketplacePkg
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
flavor,
|
||||
version: ver || '',
|
||||
...pkgInfo,
|
||||
...best,
|
||||
}
|
||||
}
|
||||
|
||||
getRequestErrors$(): Observable<string[]> {
|
||||
|
||||
@@ -6,10 +6,7 @@ export function toAcmeUrl(name: string): string {
|
||||
return knownACME.find(acme => acme.name === name)?.url || name
|
||||
}
|
||||
|
||||
export const knownACME: {
|
||||
name: string
|
||||
url: string
|
||||
}[] = [
|
||||
export const knownACME = [
|
||||
{
|
||||
name: `Let's Encrypt`,
|
||||
url: 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
@@ -18,4 +15,4 @@ export const knownACME: {
|
||||
name: `Let's Encrypt (Staging)`,
|
||||
url: 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
},
|
||||
]
|
||||
] as const
|
||||
|
||||
@@ -13,7 +13,10 @@ export function dryUpdate(
|
||||
Object.keys(pkg.currentDependencies || {}).some(
|
||||
pkgId => pkgId === id,
|
||||
) &&
|
||||
!exver.satisfies(version, pkg.currentDependencies[id].versionRange),
|
||||
!exver.satisfies(
|
||||
version,
|
||||
pkg.currentDependencies[id]?.versionRange || '',
|
||||
),
|
||||
)
|
||||
.map(pkg => getManifest(pkg).title)
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ export function getMenu() {
|
||||
const badge = inject(BadgeService)
|
||||
|
||||
return Object.keys(SYSTEM_UTILITIES).map(key => ({
|
||||
name: SYSTEM_UTILITIES[key].title,
|
||||
icon: SYSTEM_UTILITIES[key].icon,
|
||||
name: SYSTEM_UTILITIES[key]?.title || '',
|
||||
icon: SYSTEM_UTILITIES[key]?.icon || '',
|
||||
routerLink: key,
|
||||
badge: toSignal(badge.getCount(key), { initialValue: 0 }),
|
||||
}))
|
||||
|
||||
@@ -69,7 +69,6 @@ hr {
|
||||
min-height: fit-content;
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.g-aside {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user