* add support for idmapped mounts to start-sdk

* misc fixes

* misc fixes

* add default to textarea

* fix iptables masquerade rule

* fix textarea types

* more fixes

* better logging for rsync

* fix tty size

* fix wg conf generation for android

* disable file mounts on dependencies

* mostly there, some styling issues (#3069)

* mostly there, some styling issues

* fix: address comments (#3070)

* fix: address comments

* fix: fix

* show SSL for any address with secure protocol and ssl added

* better sorting and messaging

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>

* fixes for nextcloud

* allow sidebar navigation during service state traansitions

* wip: x-forwarded headers

* implement x-forwarded-for proxy

* lowercase domain names and fix warning popover bug

* fix http2 websockets

* fix websocket retry behavior

* add arch filters to s9pk pack

* use docker for start-cli install

* add version range to package signer on registry

* fix rcs < 0

* fix user information parsing

* refactor service interface getters

* disable idmaps

* build fixes

* update docker login action

* streamline build

* add start-cli workflow

* rename

* riscv64gc

* fix ui packing

* no default features on cli

* make cli depend on GIT_HASH

* more build fixes

* more build fixes

* interpolate arch within dockerfile

* fix tests

* add launch ui to service page plus other small improvements (#3075)

* add launch ui to service page plus other small improvements

* revert translation disable

* add spinner to service list if service is health and loading

* chore: some visual tune up

* chore: update Taiga UI

---------

Co-authored-by: waterplea <alexander@inkin.ru>

* fix backups

* feat: use arm hosted runners and don't fail when apt package does not exist (#3076)

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Shadowy Super Coder <musashidisciple@proton.me>
Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Remco Ros <remcoros@live.nl>
This commit is contained in:
Aiden McClelland
2025-12-15 13:30:50 -07:00
committed by GitHub
parent b945243d1a
commit 0430e0f930
148 changed files with 2572 additions and 1761 deletions

View File

@@ -549,7 +549,7 @@ export default {
584: 'Verbindungen können manchmal langsam oder unzuverlässig sein',
585: 'Öffentlich, wenn Sie die Adresse öffentlich teilen, andernfalls privat',
586: 'Erfordert ein Tor-fähiges Gerät oder einen Browser',
587: 'In den meisten Fällen nicht empfohlen. Nur erforderlich für Apps, die HTTPS erzwingen',
587: 'Sollte nur für Apps benötigt werden, die SSL erzwingen',
588: 'Ideal für anonyme, zensurresistente Bereitstellung und Fernzugriff',
589: 'Ideal für lokalen Zugriff',
590: 'Erfordert die Verbindung mit demselben lokalen Netzwerk (LAN) wie Ihr Server, entweder physisch oder über VPN',
@@ -589,4 +589,5 @@ export default {
624: 'Versionen',
625: 'Eine andere Version auswählen',
626: 'Hochladen',
627: 'UI öffnen',
} satisfies i18n

View File

@@ -548,7 +548,7 @@ export const ENGLISH = {
'Connections can be slow or unreliable at times': 584,
'Public if you share the address publicly, otherwise private': 585,
'Requires using a Tor-enabled device or browser': 586,
'Not recommended in most cases. Only needed for apps that enforce HTTPS': 587,
'Should only needed for apps that enforce SSL': 587,
'Ideal for anonymous, censorship-resistant hosting and remote access': 588,
'Ideal for local access': 589,
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN': 590,
@@ -588,4 +588,5 @@ export const ENGLISH = {
'Versions': 624,
'Select another version': 625,
'Upload': 626, // as in, upload a file
'Open UI': 627, // as in, upload a file
} as const

View File

@@ -549,7 +549,7 @@ export default {
584: 'Las conexiones pueden ser lentas o poco confiables a veces',
585: 'Público si compartes la dirección públicamente, de lo contrario privado',
586: 'Requiere un dispositivo o navegador habilitado para Tor',
587: 'No recomendado en la mayoría de los casos. Solo necesario para aplicaciones que imponen HTTPS',
587: 'Solo debería ser necesario para aplicaciones que imponen SSL',
588: 'Ideal para alojamiento y acceso remoto anónimo y resistente a la censura',
589: 'Ideal para acceso local',
590: 'Requiere estar conectado a la misma red de área local (LAN) que tu servidor, ya sea físicamente o mediante VPN',
@@ -589,4 +589,5 @@ export default {
624: 'Versiones',
625: 'Seleccionar otra versión',
626: 'Subir',
627: 'Abrir UI',
} satisfies i18n

View File

@@ -549,7 +549,7 @@ export default {
584: 'Les connexions peuvent parfois être lentes ou peu fiables',
585: 'Public si vous partagez ladresse publiquement, sinon privé',
586: 'Nécessite un appareil ou un navigateur compatible Tor',
587: 'Non recommandé dans la plupart des cas. Nécessaire uniquement pour les applications qui imposent HTTPS',
587: 'Ne devrait être nécessaire que pour les applications qui imposent SSL',
588: 'Idéal pour lhébergement et laccès à distance anonymes et résistants à la censure',
589: 'Idéal pour un accès local',
590: 'Nécessite dêtre connecté au même réseau local (LAN) que votre serveur, soit physiquement, soit via VPN',
@@ -589,4 +589,5 @@ export default {
624: 'Versions',
625: 'Sélectionner une autre version',
626: 'Téléverser',
627: 'Ouvrir UI',
} satisfies i18n

View File

@@ -549,7 +549,7 @@ export default {
584: 'Połączenia mogą być czasami wolne lub niestabilne',
585: 'Publiczne, jeśli udostępniasz adres publicznie, w przeciwnym razie prywatne',
586: 'Wymaga urządzenia lub przeglądarki obsługującej Tor',
587: 'Niezalecane w większości przypadków. Wymagane tylko dla aplikacji wymuszających HTTPS',
587: 'Powinno być wymagane tylko dla aplikacji wymuszających SSL',
588: 'Idealne do anonimowego, odpornego na cenzurę hostingu i zdalnego dostępu',
589: 'Idealne do dostępu lokalnego',
590: 'Wymaga połączenia z tą samą siecią lokalną (LAN) co serwer, fizycznie lub przez VPN',
@@ -589,4 +589,5 @@ export default {
624: 'Wersje',
625: 'Wybierz inną wersję',
626: 'Prześlij',
627: 'Otwórz UI',
} satisfies i18n

View File

@@ -112,10 +112,10 @@ tui-hint[data-appearance='onDark'] {
tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
border: 0;
backdrop-filter: blur(0.25rem);
backdrop-filter: brightness(5) blur(0.25rem);
background-color: color-mix(
in hsl,
var(--tui-background-elevation-3) 75%,
var(--tui-background-elevation-3) 85%,
transparent
);
background-image:

View File

@@ -68,7 +68,9 @@ import { QrCodeComponent } from 'ng-qrcode'
export class DevicesConfig {
protected readonly config =
injectContext<TuiDialogContext<void, string>>().data
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(this.config)}`
protected readonly href = URL.createObjectURL(
new Blob([this.config], { type: 'application/octet-stream' }),
)
}
export const DEVICES_CONFIG = new PolymorpheusComponent(DevicesConfig)

View File

@@ -38,20 +38,14 @@ export default class InitializingPage {
.openWebsocket$<T.FullProgress>(guid, {
closeObserver: {
next: () => {
this.state.retrigger(true)
this.state.retrigger(true, 250)
},
},
})
.pipe(startWith(progress)),
),
map(formatProgress),
tap(({ total }) => {
if (total === 1) {
this.state.retrigger(true)
}
}),
catchError((_, caught$) => {
this.state.retrigger(true)
return timer(500).pipe(switchMap(() => caught$))
}),
),

View File

@@ -2,29 +2,19 @@ import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { i18nPipe } from '@start9labs/shared'
import { DialogService, i18nPipe } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { tuiAsControl, TuiControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiButton,
TuiDialogContext,
TuiError,
} from '@taiga-ui/core'
import { TuiError } from '@taiga-ui/core'
import {
TUI_FORMAT_ERROR,
TUI_VALIDATION_ERRORS,
TuiFieldErrorPipe,
} from '@taiga-ui/kit'
import { PolymorpheusOutlet } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { ControlSpec } from '../controls/control'
import { CONTROLS } from '../controls/controls'
@@ -46,35 +36,6 @@ export const ERRORS = [
template: `
<ng-container *polymorpheusOutlet="controls[spec.type]" />
<tui-error [error]="order | tuiFieldError | async" />
@if (spec.warning || immutable) {
<ng-template #warning let-completeWith="completeWith">
{{ spec.warning }}
@if (immutable) {
<p>{{ 'This value cannot be changed once set' | i18n }}!</p>
}
<div [style.margin-top.rem]="0.5">
<button
tuiButton
type="button"
appearance="secondary-grayscale"
size="s"
[style.margin-inline-end.rem]="0.5"
(click)="completeWith(false)"
>
{{ 'Continue' | i18n }}
</button>
<button
tuiButton
type="button"
appearance="flat-grayscale"
size="s"
(click)="completeWith(true)"
>
{{ 'Cancel' | i18n }}
</button>
</div>
</ng-template>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
@@ -92,21 +53,13 @@ export const ERRORS = [
},
],
hostDirectives: [ControlDirective],
imports: [
AsyncPipe,
i18nPipe,
PolymorpheusOutlet,
TuiError,
TuiFieldErrorPipe,
TuiButton,
],
imports: [AsyncPipe, PolymorpheusOutlet, TuiError, TuiFieldErrorPipe],
})
export class FormControlComponent<
T extends ControlSpec,
V,
> extends TuiControl<V | null> {
private readonly destroyRef = inject(DestroyRef)
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(DialogService)
private readonly i18n = inject(i18nPipe)
protected readonly controls = CONTROLS
@@ -114,30 +67,37 @@ export class FormControlComponent<
@Input({ required: true })
spec!: T
@ViewChild('warning')
warning?: TemplateRef<TuiDialogContext<boolean>>
warned = false
readonly order = ERRORS
get immutable(): boolean {
return 'immutable' in this.spec && this.spec.immutable
}
onInput(value: V | null) {
const previous = this.value()
if (!this.warned && this.warning) {
this.alerts
.open<boolean>(this.warning, {
label: this.i18n.transform('Warning'),
appearance: 'warning',
let warning = this.spec.warning
const immutable =
'immutable' in this.spec &&
this.spec.immutable &&
`${this.i18n.transform('This value cannot be changed once set')}.`
if (immutable) {
warning = warning
? `<ul><li>${warning}</li><li>${immutable}</li></ul>`
: immutable
}
if (!this.warned && warning) {
this.dialogs
.openConfirm({
label: 'Warning',
data: { content: warning as any, yes: 'Confirm', no: 'Cancel' },
closeable: false,
autoClose: 0,
dismissible: false,
})
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.onChange(previous)
.subscribe(confirm => {
if (!confirm) {
this.onChange(previous)
}
})
}
@@ -146,7 +106,10 @@ export class FormControlComponent<
}
}
function getText({ patterns }: IST.ValueSpecText, pattern: unknown): string {
function getText(
{ patterns }: IST.ValueSpecText | IST.ValueSpecTextarea,
pattern: unknown,
): string {
return (
patterns?.find(({ regex }) => String(regex) === pattern)?.description ||
'Invalid format'

View File

@@ -21,14 +21,14 @@ import { HintPipe } from '../pipes/hint.pipe'
}
<input
tuiInputNumber
[postfix]="spec.units ? ' ' + spec.units : ''"
[postfix]="postfix"
[min]="spec.min"
[max]="spec.max"
[step]="spec.step || 0"
[invalid]="control.invalid()"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[placeholder]="spec.placeholder || ''"
[placeholder]="placeholder"
[(ngModel)]="value"
(blur)="control.onTouched()"
/>
@@ -51,4 +51,16 @@ export class FormNumberComponent extends Control<IST.ValueSpecNumber, number> {
get precision(): number {
return this.spec.integer ? 0 : Infinity
}
get postfix(): string {
return this.spec.units && (this.value !== null || !this.spec.placeholder)
? ` ${this.spec.units}`
: ''
}
get placeholder(): string {
const units = this.spec.units ? ` (${this.spec.units})` : ''
return this.spec.placeholder ? this.spec.placeholder + units : ''
}
}

View File

@@ -3,8 +3,14 @@ import { FormsModule } from '@angular/forms'
import { invert } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiIcon, TuiTextfield } from '@taiga-ui/core'
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
import { TuiDataList, TuiIcon, TuiTextfield } from '@taiga-ui/core'
import {
TuiDataListWrapper,
TuiFluidTypography,
tuiFluidTypographyOptionsProvider,
TuiSelect,
TuiTooltip,
} from '@taiga-ui/kit'
import { Control } from './control'
import { HintPipe } from '../pipes/hint.pipe'
@@ -41,18 +47,32 @@ import { HintPipe } from '../pipes/hint.pipe'
/>
}
@if (!mobile) {
<tui-data-list-wrapper *tuiTextfieldDropdown new [items]="items" />
<tui-data-list *tuiTextfieldDropdown>
@for (item of items; track $index) {
<button
tuiOption
new
tuiFluidTypography
[style.white-space]="'nowrap'"
[value]="item"
>
{{ item }}
</button>
}
</tui-data-list>
}
@if (spec | hint; as hint) {
<tui-icon [tuiTooltip]="hint" />
}
</tui-textfield>
`,
providers: [tuiFluidTypographyOptionsProvider({ max: 1 })],
imports: [
FormsModule,
TuiTextfield,
TuiSelect,
TuiDataListWrapper,
TuiDataList,
TuiFluidTypography,
TuiIcon,
TuiTooltip,
HintPipe,

View File

@@ -22,7 +22,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
class="link"
routerLinkActive="link_active"
tuiHintDirection="bottom"
[tuiHintShowDelay]="250"
[tuiHintShowDelay]="128"
[routerLink]="['/', item.routerLink]"
[class.link_system]="item.routerLink === 'system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"

View File

@@ -7,9 +7,9 @@ import { i18nKey, i18nPipe } from '@start9labs/shared'
type AddressWithInfo = {
url: string
ssl: boolean
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
@@ -30,10 +30,7 @@ function filterTor(a: AddressWithInfo): a is TorAddress {
return a.info.kind === 'onion'
}
function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (y.url.startsWith('http:') && x.url.startsWith('https:')) return sign
}
return 0
return cmpWithRankedPredicates(a, b, [x => !x.showSsl])
}
type LanAddress = AddressWithInfo & { info: { kind: 'ip'; public: false } }
@@ -146,10 +143,15 @@ export class InterfaceService {
: undefined
const res = []
if (url) {
res.push({ url, ssl: false, info, gateway })
res.push({ url, info, gateway, showSsl: false })
}
if (sslUrl) {
res.push({ url: sslUrl, ssl: true, info, gateway })
res.push({
url: sslUrl,
info,
gateway,
showSsl: !!url,
})
}
return res
},
@@ -326,7 +328,7 @@ export class InterfaceService {
}
private toDisplayAddress(
{ info, ssl, url, gateway }: AddressWithInfo,
{ info, url, gateway, showSsl }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
@@ -351,15 +353,8 @@ export class InterfaceService {
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (SSL)
if (ssl) {
type = `${type} (SSL)`
bullets = [
this.i18n.transform(
'Not recommended in most cases. Only needed for apps that enforce HTTPS',
),
rootCaRequired,
...bullets,
]
if (showSsl) {
bullets = [rootCaRequired, ...bullets]
// Tor (NON-SSL)
} else {
bullets.unshift(
@@ -500,6 +495,14 @@ export class InterfaceService {
}
}
if (showSsl) {
type = `${type} (SSL)`
bullets.unshift(
this.i18n.transform('Should only needed for apps that enforce SSL'),
)
}
return {
url,
access,

View File

@@ -68,7 +68,7 @@ export class PublicDomainService {
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}).map(f => f.toLocaleLowerCase()),
...this.gatewayAndAuthoritySpec(),
})

View File

@@ -1,119 +1,131 @@
import { TuiLineClamp } from '@taiga-ui/kit'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Input,
input,
} from '@angular/core'
import { toObservable, toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { i18nPipe } from '@start9labs/shared'
import { TuiIcon, TuiLink } from '@taiga-ui/core'
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { first, Observable } from 'rxjs'
import { EMPTY, first, switchMap } from 'rxjs'
import { ServerNotification } from 'src/app/services/api/api.types'
import { NotificationService } from 'src/app/services/notification.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toRouterLink } from 'src/app/utils/to-router-link'
import { i18nPipe } from '@start9labs/shared'
@Component({
selector: '[notificationItem]',
template: `
<td class="checkbox"><ng-content /></td>
<td class="date">
{{ notificationItem.createdAt | date: 'medium' }}
</td>
<td class="title" [style.color]="color">
<tui-icon [icon]="icon" [style.font-size.rem]="1" />
{{ notificationItem.title }}
</td>
<td class="service">
@if (manifest$ | async; as manifest) {
<a tuiLink [routerLink]="getLink(manifest.id)">
{{ manifest.title }}
</a>
} @else if (notificationItem.packageId) {
{{ notificationItem.packageId }}
} @else {
-
}
</td>
<td class="content">
<tui-line-clamp
style="pointer-events: none"
[linesLimit]="4"
[lineHeight]="21"
[content]="notificationItem.message"
(overflownChange)="overflow = $event"
/>
@if (overflow) {
<button tuiLink (click.stop)="onClick()">
{{ 'View full' | i18n }}
</button>
}
@if ([1, 2].includes(notificationItem.code)) {
<button tuiLink (click.stop)="onClick()">
{{
notificationItem.code === 1
? ('View report' | i18n)
: ('View details' | i18n)
}}
</button>
}
</td>
@if (notificationItem(); as item) {
<td>
<ng-content />
{{ item.createdAt | date: 'MMM d, y, h:mm a' }}
</td>
<td class="title" [style.color]="color()">
<tui-icon [icon]="icon()" />
{{ item.title }}
</td>
<td class="service">
@if (pkg(); as pkg) {
@if (pkg.stateInfo.manifest; as manifest) {
<a
tuiAvatar
size="s"
[routerLink]="'/services/' + manifest.id"
[title]="manifest.title"
>
<img [src]="pkg.icon" [alt]="manifest.title" />
</a>
} @else {
{{ item.packageId || '-' }}
}
} @else {
-
}
</td>
<td class="content">
<tui-line-clamp
style="pointer-events: none"
[linesLimit]="4"
[lineHeight]="21"
[content]="item.message"
(overflownChange)="overflow = $event"
/>
@if (overflow) {
<button tuiLink (click.stop)="onClick(item)">
{{ 'View full' | i18n }}
</button>
}
@if ([1, 2].includes(item.code)) {
<button tuiLink (click.stop)="onClick(item)">
{{
item?.code === 1
? ('View report' | i18n)
: ('View details' | i18n)
}}
</button>
}
</td>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class._new]': '!notificationItem.seen',
'[class._new]': '!notificationItem()?.seen',
},
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
:host._new td {
font-weight: bold;
color: var(--tui-text-primary);
}
:host {
grid-template-columns: 1fr;
.title {
width: 13rem;
}
&._new td {
font-weight: bold;
color: var(--tui-text-primary);
&.checkbox {
box-shadow: inset 0.25rem 0 var(--tui-text-action);
}
}
.service {
width: 4.25rem;
text-align: center;
grid-column: 2;
grid-row: 1 / 3;
place-content: center;
}
tui-icon {
vertical-align: text-top;
align-self: center;
}
button {
position: relative;
font-size: 1rem;
vertical-align: sub;
}
td {
padding: 0.25rem;
vertical-align: top;
color: var(--tui-text-secondary);
}
grid-column: 1;
.checkbox {
padding-top: 0.4rem;
&:first-child {
width: 12rem;
padding-inline-start: 2.5rem;
white-space: nowrap;
}
&:last-child {
grid-column: 1 / 3;
}
}
:host-context(tui-root._mobile) {
gap: 0.5rem;
padding: 0.75rem 1rem !important;
.checkbox {
@include taiga.fullsize();
:host {
grid-template-columns: 1fr 2rem;
user-select: none;
gap: 0.5rem;
}
.date {
order: 1;
td:first-child {
padding: 0;
font: var(--tui-font-text-s);
color: var(--tui-text-secondary);
margin-block-end: -0.25rem;
}
.title {
@@ -121,59 +133,65 @@ import { i18nPipe } from '@start9labs/shared'
font-size: 1.2em;
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.375rem;
}
.service:not(:has(a)) {
display: none;
}
}
.service {
width: auto;
:host-context(tui-root._mobile table:has(:checked)) tui-icon {
opacity: 0;
&:not(:has(a)) {
display: none;
}
}
:host-context(table:has(:checked)) tui-icon {
opacity: 0;
}
}
`,
imports: [CommonModule, RouterLink, TuiLineClamp, TuiLink, TuiIcon, i18nPipe],
imports: [
CommonModule,
RouterLink,
TuiLineClamp,
TuiLink,
TuiIcon,
i18nPipe,
TuiAvatar,
],
})
export class NotificationItemComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly service = inject(NotificationService)
@Input({ required: true }) notificationItem!: ServerNotification<number>
readonly notificationItem = input<ServerNotification<number>>()
readonly color = computed((item = this.notificationItem()) =>
item ? this.service.getColor(item) : '',
)
readonly icon = computed((item = this.notificationItem()) =>
item ? this.service.getIcon(item) : '',
)
readonly pkg = toSignal(
toObservable(this.notificationItem).pipe(
switchMap(item =>
item
? this.patch.watch$('packageData', item.packageId || '').pipe(first())
: EMPTY,
),
),
)
overflow = false
@tuiPure
get manifest$(): Observable<T.Manifest> {
return this.patch
.watch$(
'packageData',
this.notificationItem.packageId || '',
'stateInfo',
'manifest',
)
.pipe(first())
}
get color(): string {
return this.service.getColor(this.notificationItem)
}
get icon(): string {
return this.service.getIcon(this.notificationItem)
}
getLink(id: string) {
return toRouterLink(id)
}
onClick() {
onClick(item: ServerNotification<number>) {
if (this.overflow) {
this.service.viewModal(this.notificationItem, true)
this.notificationItem.seen = true
} else if ([1, 2].includes(this.notificationItem.code)) {
this.service.viewModal(this.notificationItem)
this.notificationItem.seen = true
this.service.viewModal(item, true)
item.seen = true
} else if ([1, 2].includes(item.code)) {
this.service.viewModal(item)
item.seen = true
}
}
}

View File

@@ -1,15 +1,24 @@
import { NgTemplateOutlet } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ErrorService, i18nPipe, isEmptyObject } from '@start9labs/shared'
import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core'
import { RR, ServerNotifications } from 'src/app/services/api/api.types'
import {
ErrorService,
i18nPipe,
isEmptyObject,
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import {
RR,
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { NotificationService } from 'src/app/services/notification.service'
import { TitleDirective } from 'src/app/services/title.service'
@@ -17,55 +26,23 @@ import { NotificationsTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>
{{ 'Notifications' | i18n }}
<ng-container *ngTemplateOutlet="button" />
</ng-container>
<ng-container *title>{{ 'Notifications' | i18n }}</ng-container>
<section class="g-card">
<header>
{{ 'Notifications' | i18n }}
<ng-container *ngTemplateOutlet="button" />
</header>
<table #table class="g-table" [notifications]="notifications()"></table>
<ng-template #button>
<button
appearance="primary"
iconEnd="@tui.chevron-down"
tuiButton
size="xs"
type="button"
tuiDropdownOpen
tuiDropdownAlign="right"
[tuiDropdown]="dropdown"
[tuiDropdownEnabled]="!!table.selected().length"
[style.margin-inline-start.rem]="0.5"
[disabled]="!table.selected().length"
iconStart="@tui.trash"
appearance="primary-destructive"
[style.margin]="'0 0.5rem 0 auto'"
[disabled]="!tableNotifications()?.selected()?.length"
(click)="remove(notifications() || [])"
>
{{ 'Actions' | i18n }}
<ng-template #dropdown let-close>
<tui-data-list (click)="close()">
<button
tuiOption
(click)="markSeen(notifications(), table.selected())"
>
{{ 'Mark seen' | i18n }}
</button>
<button
tuiOption
(click)="markUnseen(notifications(), table.selected())"
>
{{ 'Mark unseen' | i18n }}
</button>
<button
tuiOption
(click)="remove(notifications(), table.selected())"
>
{{ 'Delete' | i18n }}
</button>
</tui-data-list>
</ng-template>
{{ 'Delete selected' | i18n }}
</button>
</ng-template>
</header>
<div #table [notifications]="notifications()"></div>
</section>
`,
styles: `
@@ -85,80 +62,59 @@ import { NotificationsTableComponent } from './table.component'
`,
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiDropdown,
TuiButton,
TuiDataList,
NotificationsTableComponent,
TitleDirective,
i18nPipe,
NgTemplateOutlet,
],
imports: [TuiButton, NotificationsTableComponent, TitleDirective, i18nPipe],
})
export default class NotificationsComponent implements OnInit {
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
private readonly loader = inject(LoadingService)
readonly service = inject(NotificationService)
readonly api = inject(ApiService)
readonly errorService = inject(ErrorService)
readonly notifications = signal<ServerNotifications | undefined>(undefined)
readonly notifications = signal<ServerNotifications | null>(null)
protected tableNotifications =
viewChild<NotificationsTableComponent<ServerNotification<number>>>('table')
ngOnInit() {
this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({})
this.getMore({}).then(() => {
const latest = this.notifications()?.at(0)
if (latest) {
this.service.markSeenAll(latest.id)
}
})
}
})
}
async getMore(params: RR.GetNotificationsReq) {
try {
this.notifications.set(undefined)
this.notifications.set(null)
this.notifications.set(await this.api.getNotifications(params))
} catch (e: any) {
this.errorService.handleError(e)
}
}
markSeen(
current: ServerNotifications = [],
toUpdate: ServerNotifications = [],
) {
this.notifications.set(
current.map(c => ({
...c,
seen: toUpdate.some(n => n.id === c.id) || c.seen,
})),
)
async remove(all: ServerNotifications) {
const ids =
this.tableNotifications()
?.selected()
.map(n => n.id) || []
const loader = this.loader.open('Deleting').subscribe()
this.service.markSeen(toUpdate)
}
markUnseen(
current: ServerNotifications = [],
toUpdate: ServerNotifications = [],
) {
this.notifications.set(
current.map(c => ({
...c,
seen: c.seen && !toUpdate.some(n => n.id === c.id),
})),
)
this.service.markUnseen(toUpdate)
}
remove(
current: ServerNotifications = [],
toDelete: ServerNotifications = [],
) {
this.notifications.set(
current.filter(c => !toDelete.some(n => n.id === c.id)),
)
this.service.remove(toDelete)
try {
await this.api.deleteNotifications({ ids })
this.notifications.set(all.filter(n => !ids.includes(n.id)))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,89 +1,85 @@
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
computed,
input,
OnChanges,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import {
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { ServerNotification } from 'src/app/services/api/api.types'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { NotificationItemComponent } from './item.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
selector: 'table[notifications]',
selector: '[notifications]',
template: `
<thead>
<tr>
<th [style.width.rem]="1.5">
<table [appTable]="['Title', 'Service', 'Message']">
<th [style.text-indent.rem]="1.75">
<input
tuiCheckbox
size="s"
type="checkbox"
[disabled]="!notifications()"
[ngModel]="all()"
(ngModelChange)="selected.set(($event && notifications()) || [])"
/>
{{ 'Date' | i18n }}
</th>
@for (not of notifications(); track not) {
<tr
[notificationItem]="not"
(longtap)="!selected().length && onToggle(not)"
(click)="
selected().length &&
$any($event.target).closest('tui-root._mobile') &&
onToggle(not)
"
>
<input
tuiCheckbox
size="s"
type="checkbox"
[style.display]="'block'"
[disabled]="!notifications?.length"
[ngModel]="all"
(ngModelChange)="onAll($event)"
[ngModel]="selected().includes(not)"
(ngModelChange)="onToggle(not)"
/>
</th>
<th [style.min-width.rem]="12">{{ 'Date' | i18n }}</th>
<th [style.min-width.rem]="14">{{ 'Title' | i18n }}</th>
<th [style.min-width.rem]="8">{{ 'Service' | i18n }}</th>
<th>{{ 'Message' | i18n }}</th>
</tr>
</thead>
<tbody>
@if (notifications) {
@for (notification of notifications; track $index) {
<tr
[notificationItem]="notification"
(longtap)="!selected().length && onToggle(notification)"
(click.capture)="
selected().length &&
$any($event.target).closest('tui-root._mobile') &&
onToggle(notification, $event)
"
>
<input
tuiCheckbox
size="s"
type="checkbox"
[style.display]="'block'"
[ngModel]="selected().includes(notification)"
(ngModelChange)="onToggle(notification)"
(click.stop)="(0)"
/>
</tr>
} @empty {
</tr>
} @empty {
@if (notifications()) {
<tr>
<td colspan="5">{{ 'No notifications' | i18n }}</td>
</tr>
}
} @else {
@for (row of ['', '']; track $index) {
<tr>
<td colspan="5">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
<td colspan="4">{{ 'No notifications' | i18n }}</td>
</tr>
} @else {
@for (i of ['', '']; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
</tbody>
</table>
`,
styles: `
:host-context(tui-root._mobile) {
margin: 0 -1rem;
input {
position: absolute;
top: 50%;
left: 0.75rem;
transform: translateY(-50%);
}
td:only-child {
text-align: center;
}
:host-context(tui-root._mobile) {
input {
position: absolute;
top: 0.875rem;
left: 1rem;
top: 2.875rem;
left: 0;
z-index: 1;
pointer-events: none;
}
@@ -100,40 +96,30 @@ import { i18nPipe } from '@start9labs/shared'
NotificationItemComponent,
TuiSkeleton,
i18nPipe,
TableComponent,
],
})
export class NotificationsTableComponent implements OnChanges {
@Input() notifications?: ServerNotifications
export class NotificationsTableComponent<T extends ServerNotification<number>>
implements OnChanges
{
readonly notifications = input<readonly T[] | null>(null)
get all(): boolean | null {
if (!this.notifications?.length || !this.selected().length) {
return false
}
if (this.notifications?.length === this.selected().length) {
return true
}
return null
}
readonly selected = signal<ServerNotifications>([])
readonly selected = signal<readonly T[]>([])
readonly all = computed(
() =>
!!this.selected()?.length &&
(this.selected().length === this.notifications()?.length || null),
)
ngOnChanges() {
this.selected.set([])
}
onAll(selected: boolean) {
this.selected.set((selected && this.notifications) || [])
}
onToggle(notification: ServerNotification<number>, event?: Event) {
event?.stopPropagation()
if (this.selected().some(s => s.id === notification.id)) {
this.selected.update(value => value.filter(s => s.id !== notification.id))
onToggle(notification: T) {
if (this.selected().includes(notification)) {
this.selected.update(selected => selected.filter(s => s !== notification))
} else {
this.selected.update(value => [...value, notification])
this.selected.update(selected => [...selected, notification])
}
}
}

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiTitle } from '@taiga-ui/core'
@@ -13,27 +18,25 @@ interface ActionItem {
selector: '[action]',
template: `
<div tuiTitle>
<strong>{{ action.name }}</strong>
<div tuiSubtitle [innerHTML]="action.description"></div>
@if (disabled) {
<div tuiSubtitle class="g-warning">{{ disabled }}</div>
<strong>{{ action().name }}</strong>
<div tuiSubtitle [innerHTML]="action().description"></div>
@if (disabled()) {
<div tuiSubtitle class="g-warning">{{ disabled() }}</div>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle],
host: {
'[disabled]': '!!disabled',
'[disabled]': '!!disabled() || inactive()',
},
})
export class ServiceActionComponent {
@Input({ required: true })
action!: ActionItem
action = input.required<ActionItem>()
inactive = input.required<boolean>()
get disabled() {
return (
typeof this.action.visibility === 'object' &&
this.action.visibility.disabled
)
}
disabled = computed(
(action = this.action()) =>
typeof action.visibility === 'object' && action.visibility.disabled,
)
}

View File

@@ -3,17 +3,21 @@ import {
ChangeDetectionStrategy,
Component,
computed,
DOCUMENT,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core'
import { TuiChevron } from '@taiga-ui/kit'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { InterfaceService } from '../../../components/interfaces/interface.service'
@Component({
selector: 'service-controls',
@@ -27,16 +31,55 @@ import { getManifest } from 'src/app/utils/get-package-data'
>
{{ 'Stop' | i18n }}
</button>
}
@if (status() === 'running') {
<button
tuiButton
iconStart="@tui.rotate-cw"
(click)="controls.restart(manifest())"
>
{{ 'Restart' | i18n }}
</button>
@if (status() === 'running') {
<button
tuiButton
iconStart="@tui.rotate-cw"
(click)="controls.restart(manifest())"
>
{{ 'Restart' | i18n }}
</button>
@if (interfaces().length > 1) {
<button
tuiButton
appearance="primary-grayscale"
iconStart="@tui.external-link"
tuiChevron
tuiDropdownOpen
tuiDropdownLimitWidth="fixed"
[tuiDropdown]="content"
>
{{ 'Open UI' | i18n }}
</button>
<ng-template #content>
<tui-data-list>
@for (i of interfaces(); track $index) {
<a
tuiOption
new
target="_blank"
rel="noreferrer"
iconEnd="@tui.external-link"
[attr.href]="getHref(i)"
>
{{ i.name }}
</a>
}
</tui-data-list>
</ng-template>
} @else if (interfaces()[0]) {
<button
tuiButton
appearance="primary-grayscale"
iconStart="@tui.external-link"
(click)="openUI(interfaces()[0]!)"
>
{{ 'Open UI' | i18n }}
</button>
}
}
}
@if (status() === 'stopped') {
@@ -82,10 +125,19 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, AsyncPipe],
imports: [
TuiButton,
i18nPipe,
AsyncPipe,
TuiDataList,
TuiDropdown,
TuiChevron,
],
})
export class ServiceControlsComponent {
private readonly errors = inject(DepErrorService)
private readonly interfaceService = inject(InterfaceService)
private readonly document = inject(DOCUMENT)
readonly pkg = input.required<PackageDataEntry>()
readonly status = input<PrimaryStatus>()
@@ -101,4 +153,23 @@ export class ServiceControlsComponent {
),
),
)
readonly interfaces = computed(() =>
Object.values(this.pkg().serviceInterfaces).filter(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
),
)
getHref(ui: T.ServiceInterface): string {
const host = this.pkg().hosts[ui.addressInfo.hostId]
if (!host) return ''
return this.interfaceService.launchableAddress(ui, host)
}
openUI(ui: T.ServiceInterface) {
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
}
}

View File

@@ -118,7 +118,7 @@ export class ServiceDependenciesComponent {
getHealthCheckName(id: string) {
const depError = this.errors()[id]
return depError?.type === 'healthChecksFailed'
? depError.check.name
? depError.check?.name
: undefined
}
}

View File

@@ -1,14 +1,6 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { InterfaceService } from 'src/app/routes/portal/components/interfaces/interface.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'tr[serviceInterface]',
@@ -20,19 +12,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
<td class="g-secondary" [style.grid-area]="'2 / 1 / 2 / 3'">
{{ info.description }}
</td>
<td>
@if (info.type === 'ui') {
<a
tuiIconButton
iconStart="@tui.external-link"
appearance="flat-grayscale"
target="_blank"
rel="noopener noreferrer"
[attr.href]="disabled ? null : href"
(click.stop)="(0)"
></a>
}
</td>
`,
styles: `
:host {
@@ -57,15 +36,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
font-size: 1rem;
}
td:last-child {
grid-area: 1 / 3 / span 2 / 3;
white-space: nowrap;
text-align: right;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content;
@@ -79,20 +49,12 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiBadge],
imports: [TuiBadge],
})
export class ServiceInterfaceItemComponent {
private readonly interfaceService = inject(InterfaceService)
@Input({ required: true })
info!: T.ServiceInterface
@Input({ required: true })
pkg!: PackageDataEntry
@Input()
disabled = false
get appearance(): string {
switch (this.info.type) {
case 'ui':
@@ -103,12 +65,4 @@ export class ServiceInterfaceItemComponent {
return 'negative'
}
}
get href() {
const host = this.pkg.hosts[this.info.addressInfo.hostId]
return host
? this.interfaceService.launchableAddress(this.info, host)
: null
}
}

View File

@@ -22,7 +22,6 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@@ -31,8 +30,6 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
tabindex="-1"
serviceInterface
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
[routerLink]="info.routerLink"
>
<a [routerLink]="info.routerLink">
@@ -64,16 +61,13 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
})
export class ServiceInterfacesComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly disabled = input(false)
readonly interfaces = computed(({ serviceInterfaces } = this.pkg()) =>
Object.entries(serviceInterfaces)
.sort((a, b) => tuiDefaultSort(a[1], b[1]))
.map(([id, value]) => {
return {
...value,
routerLink: `./interface/${id}`,
}
}),
.map(([id, value]) => ({
...value,
routerLink: `./interface/${id}`,
})),
)
}

View File

@@ -50,12 +50,12 @@ import { getManifest } from 'src/app/utils/get-package-data'
<td class="g-secondary" [style.grid-row]="3">
{{ task().reason || ('No reason provided' | i18n) }}
</td>
<td [style.grid-area]="'2 / 2 / 4'">
<td>
@if (task().severity !== 'critical') {
<button
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
appearance="primary-destructive"
[disabled]="!pkg()"
(click)="dismiss()"
>
@@ -65,7 +65,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button
tuiIconButton
iconStart="@tui.play"
appearance="flat-grayscale"
appearance="primary-success"
[disabled]="!pkg()"
(click)="handle()"
>
@@ -87,7 +87,8 @@ import { getManifest } from 'src/app/utils/get-package-data'
td:last-child {
white-space: nowrap;
text-align: right;
justify-content: end;
display: flex;
gap: 8px;
}
span {

View File

@@ -1,80 +1,80 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { UILaunchComponent } from './ui-launch.component'
// import { AsyncPipe } from '@angular/common'
// import {
// ChangeDetectionStrategy,
// Component,
// computed,
// inject,
// input,
// } from '@angular/core'
// import { i18nPipe } from '@start9labs/shared'
// import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
// import { map } from 'rxjs'
// import { ControlsService } from 'src/app/services/controls.service'
// import { DepErrorService } from 'src/app/services/dep-error.service'
// import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
// import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
// import { getManifest } from 'src/app/utils/get-package-data'
// import { UILaunchComponent } from './ui-launch.component'
const RUNNING = ['running', 'starting', 'restarting']
// const RUNNING = ['running', 'starting', 'restarting']
@Component({
selector: 'fieldset[appControls]',
template: `
<app-ui-launch [pkg]="pkg()" />
@if (running()) {
<button
tuiIconButton
iconStart="@tui.square"
(click)="controls.stop(manifest())"
>
{{ 'Stop' | i18n }}
</button>
} @else {
@let unmet = hasUnmet() | async;
<button
tuiIconButton
iconStart="@tui.play"
[disabled]="status().primary !== 'stopped'"
(click)="controls.start(manifest(), !!unmet)"
>
{{ 'Start' | i18n }}
</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UILaunchComponent, AsyncPipe, i18nPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
styles: `
:host {
padding: 0;
border: none;
cursor: default;
text-align: right;
}
// @Component({
// selector: 'fieldset[appControls]',
// template: `
// <app-ui-launch [pkg]="pkg()" />
// @if (running()) {
// <button
// tuiIconButton
// iconStart="@tui.square"
// (click)="controls.stop(manifest())"
// >
// {{ 'Stop' | i18n }}
// </button>
// } @else {
// @let unmet = hasUnmet() | async;
// <button
// tuiIconButton
// iconStart="@tui.play"
// [disabled]="status().primary !== 'stopped'"
// (click)="controls.start(manifest(), !!unmet)"
// >
// {{ 'Start' | i18n }}
// </button>
// }
// `,
// changeDetection: ChangeDetectionStrategy.OnPush,
// imports: [TuiButton, UILaunchComponent, AsyncPipe, i18nPipe],
// providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
// styles: `
// :host {
// padding: 0;
// border: none;
// cursor: default;
// text-align: right;
// }
:host-context(tui-root._mobile) {
button {
display: none;
}
}
`,
})
export class ControlsComponent {
private readonly errors = inject(DepErrorService)
// :host-context(tui-root._mobile) {
// button {
// display: none;
// }
// }
// `,
// })
// export class ControlsComponent {
// private readonly errors = inject(DepErrorService)
readonly controls = inject(ControlsService)
readonly pkg = input.required<PackageDataEntry>()
readonly status = computed(() => renderPkgStatus(this.pkg()))
readonly running = computed(() => RUNNING.includes(this.status().primary))
readonly manifest = computed(() => getManifest(this.pkg()))
readonly hasUnmet = computed(() =>
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
map(errors =>
Object.keys(this.pkg().currentDependencies)
.map(id => errors?.[id])
.some(Boolean),
),
),
)
}
// readonly controls = inject(ControlsService)
// readonly pkg = input.required<PackageDataEntry>()
// readonly status = computed(() => renderPkgStatus(this.pkg()))
// readonly running = computed(() => RUNNING.includes(this.status().primary))
// readonly manifest = computed(() => getManifest(this.pkg()))
// readonly hasUnmet = computed(() =>
// this.errors.getPkgDepErrors$(this.manifest().id).pipe(
// map(errors =>
// Object.keys(this.pkg().currentDependencies)
// .map(id => errors?.[id])
// .some(Boolean),
// ),
// ),
// )
// }

View File

@@ -44,7 +44,6 @@ import { ServiceComponent } from './service.component'
>
{{ 'Uptime' | i18n }}
</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5"></th>
</tr>
</thead>
<tbody>
@@ -82,6 +81,11 @@ import { ServiceComponent } from './service.component'
font-size: 1rem;
}
table {
max-width: 60rem;
margin: 0 auto;
}
:host-context(tui-root._mobile) {
padding: 0;
}

View File

@@ -1,4 +1,3 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -14,7 +13,6 @@ import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { ControlsComponent } from './controls.component'
import { StatusComponent } from './status.component'
@Component({
@@ -23,7 +21,7 @@ import { StatusComponent } from './status.component'
<td [style.grid-area]="'1 / 1 / 4'">
<img alt="logo" [src]="pkg.icon" />
</td>
<td [style.grid-area]="'1 / 2'">
<td class="title">
<a [routerLink]="routerLink">{{ manifest.title }}</a>
</td>
<td
@@ -32,7 +30,7 @@ import { StatusComponent } from './status.component'
[hasDepErrors]="hasError(depErrors)"
[style.grid-area]="'3 / 2'"
></td>
<td [style.grid-area]="'2 / 2'">{{ manifest.version }}</td>
<td class="version">{{ manifest.version }}</td>
<td class="uptime">
@if (pkg.statusInfo.started; as started) {
<span>{{ 'Uptime' | i18n }}:</span>
@@ -41,14 +39,6 @@ import { StatusComponent } from './status.component'
-
}
</td>
<td [style.grid-area]="'2 / 3'" [style.text-align]="'center'">
<fieldset
appControls
[disabled]="!installed || !(connected$ | async)"
[pkg]="pkg"
(click.stop)="(0)"
></fieldset>
</td>
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
@@ -83,14 +73,10 @@ import { StatusComponent } from './status.component'
display: none;
}
.text {
display: contents;
}
:host-context(tui-root._mobile) {
position: relative;
display: grid;
grid-template: 1.25rem 1.5rem 1.5rem/4rem 1fr 2rem;
grid-template: 1.25rem 1.5rem 1.5rem/4rem 1fr;
align-items: center;
padding: 1rem;
@@ -115,6 +101,16 @@ import { StatusComponent } from './status.component'
}
}
.title {
grid-area: 2 / 2;
font: var(--tui-font-heading-6);
}
.version {
grid-area: 1 / 2;
font: var(--tui-font-text-s);
}
.uptime {
grid-area: 4 / 2;
display: flex;
@@ -133,14 +129,7 @@ import { StatusComponent } from './status.component'
`,
hostDirectives: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
AsyncPipe,
StatusComponent,
ControlsComponent,
ServiceUptimeComponent,
i18nPipe,
],
imports: [RouterLink, StatusComponent, ServiceUptimeComponent, i18nPipe],
})
export class ServiceComponent implements OnChanges {
private readonly link = inject(RouterLink)

View File

@@ -1,12 +1,12 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Input,
input,
} from '@angular/core'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiIcon } from '@taiga-ui/core'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { getProgressText } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import {
@@ -17,13 +17,15 @@ import {
@Component({
selector: 'td[appStatus]',
template: `
@if (!healthy) {
@if (error()) {
<tui-icon icon="@tui.triangle-alert" class="g-warning" />
} @else if (loading()) {
<tui-loader size="m" />
}
<b [style.color]="color">{{ status | i18n }}</b>
<b [style.color]="color()">{{ statusText() | i18n }}</b>
@if (showDots) {
@if (showDots()) {
<span class="loading-dots g-info"></span>
}
`,
@@ -41,56 +43,50 @@ import {
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, i18nPipe],
imports: [TuiIcon, i18nPipe, TuiLoader],
})
export class StatusComponent {
@Input()
pkg!: PackageDataEntry
@Input()
hasDepErrors = false
readonly pkg = input.required<PackageDataEntry>()
readonly hasDepErrors = input<boolean>(false)
private readonly i18n = inject(i18nPipe)
get healthy(): boolean {
const { primary, health } = this.getStatus(this.pkg)
return (
!this.hasDepErrors &&
primary !== 'task-required' &&
primary !== 'error' &&
health !== 'failure'
)
}
readonly status = computed((pkg = this.pkg()) => renderPkgStatus(pkg))
@tuiPure
getStatus(pkg: PackageDataEntry) {
return renderPkgStatus(pkg)
}
readonly statusText = computed(
(pkg = this.pkg(), { primary } = this.status()) =>
pkg.stateInfo.installingInfo
? (`${this.i18n.transform('Installing')}... ${this.i18n.transform(getProgressText(pkg.stateInfo.installingInfo.progress.overall))}` as i18nKey)
: PrimaryRendering[primary].display,
)
get status(): i18nKey {
if (this.pkg.stateInfo.installingInfo) {
return `${this.i18n.transform('Installing')}... ${this.i18n.transform(getProgressText(this.pkg.stateInfo.installingInfo.progress.overall))}` as i18nKey
}
readonly error = computed(
({ primary, health } = this.status()) =>
this.hasDepErrors() ||
primary === 'task-required' ||
primary === 'error' ||
health === 'failure',
)
return PrimaryRendering[this.getStatus(this.pkg).primary].display
}
readonly loading = computed(
({ primary, health } = this.status()) =>
primary === 'running' && health === 'loading',
)
get showDots() {
switch (this.getStatus(this.pkg).primary) {
case 'updating':
case 'stopping':
case 'starting':
case 'backing-up':
case 'restarting':
case 'removing':
return true
default:
return false
}
}
readonly showDots = computed(({ primary } = this.status()) =>
[
'starting',
'stopping',
'restarting',
'installing',
'updating',
'backing-up',
'removing',
].includes(primary),
)
get color(): string {
switch (this.getStatus(this.pkg).primary) {
readonly color = computed(({ primary } = this.status()) => {
switch (primary) {
case 'running':
return 'var(--tui-status-positive)'
case 'task-required':
@@ -106,9 +102,8 @@ export class StatusComponent {
case 'removing':
case 'restoring':
return 'var(--tui-status-info)'
// stopped
default:
case 'stopped':
return 'var(--tui-text-secondary)'
}
}
})
}

View File

@@ -1,98 +1,98 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
DOCUMENT,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
// import {
// ChangeDetectionStrategy,
// Component,
// inject,
// Input,
// DOCUMENT,
// } from '@angular/core'
// import { i18nPipe } from '@start9labs/shared'
// import { T } from '@start9labs/start-sdk'
// import { tuiPure } from '@taiga-ui/cdk'
// import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
// import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
// import { InterfaceService } from '../../../components/interfaces/interface.service'
// import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'app-ui-launch',
template: `
@if (interfaces.length > 1) {
<button
tuiIconButton
iconStart="@tui.external-link"
tuiDropdownOpen
[disabled]="!isRunning"
[tuiDropdown]="content"
>
{{ 'Open' | i18n }}
</button>
<ng-template #content>
<tui-data-list>
@for (interface of interfaces; track $index) {
<a
tuiOption
target="_blank"
rel="noreferrer"
[attr.href]="getHref(interface)"
>
{{ interface.name }}
</a>
}
</tui-data-list>
</ng-template>
} @else if (interfaces[0]) {
<button
tuiIconButton
iconStart="@tui.external-link"
[disabled]="!isRunning"
(click)="openUI(interfaces[0])"
>
{{ interfaces[0].name }}
</button>
}
`,
styles: `
:host-context(tui-root._mobile) *::before {
font-size: 1.5rem !important;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe],
})
export class UILaunchComponent {
private readonly interfaceService = inject(InterfaceService)
private readonly document = inject(DOCUMENT)
// @Component({
// selector: 'app-ui-launch',
// template: `
// @if (interfaces.length > 1) {
// <button
// tuiIconButton
// iconStart="@tui.external-link"
// tuiDropdownOpen
// [disabled]="!isRunning"
// [tuiDropdown]="content"
// >
// {{ 'Open' | i18n }}
// </button>
// <ng-template #content>
// <tui-data-list>
// @for (interface of interfaces; track $index) {
// <a
// tuiOption
// target="_blank"
// rel="noreferrer"
// [attr.href]="getHref(interface)"
// >
// {{ interface.name }}
// </a>
// }
// </tui-data-list>
// </ng-template>
// } @else if (interfaces[0]) {
// <button
// tuiIconButton
// iconStart="@tui.external-link"
// [disabled]="!isRunning"
// (click)="openUI(interfaces[0])"
// >
// {{ interfaces[0].name }}
// </button>
// }
// `,
// styles: `
// :host-context(tui-root._mobile) *::before {
// font-size: 1.5rem !important;
// }
// `,
// changeDetection: ChangeDetectionStrategy.OnPush,
// imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe],
// })
// export class UILaunchComponent {
// private readonly interfaceService = inject(InterfaceService)
// private readonly document = inject(DOCUMENT)
@Input()
pkg!: PackageDataEntry
// @Input()
// pkg!: PackageDataEntry
get interfaces(): readonly T.ServiceInterface[] {
return this.getInterfaces(this.pkg)
}
// get interfaces(): readonly T.ServiceInterface[] {
// return this.getInterfaces(this.pkg)
// }
get isRunning(): boolean {
return getInstalledPrimaryStatus(this.pkg) === 'running'
}
// get isRunning(): boolean {
// return getInstalledPrimaryStatus(this.pkg) === 'running'
// }
@tuiPure
getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
return pkg
? Object.values(pkg.serviceInterfaces).filter(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
)
: []
}
// @tuiPure
// getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
// return pkg
// ? Object.values(pkg.serviceInterfaces).filter(
// i =>
// i.type === 'ui' &&
// (i.addressInfo.scheme === 'http' ||
// i.addressInfo.sslScheme === 'https'),
// )
// : []
// }
getHref(ui: T.ServiceInterface): string {
const host = this.pkg.hosts[ui.addressInfo.hostId]
if (!host) return ''
return this.interfaceService.launchableAddress(ui, host)
}
// getHref(ui: T.ServiceInterface): string {
// const host = this.pkg.hosts[ui.addressInfo.hostId]
// if (!host) return ''
// return this.interfaceService.launchableAddress(ui, host)
// }
openUI(ui: T.ServiceInterface) {
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
}
}
// openUI(ui: T.ServiceInterface) {
// this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
// }
// }

View File

@@ -27,7 +27,7 @@ import { TaskInfoComponent } from 'src/app/routes/portal/modals/config-dep.compo
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BaseStatus } from 'src/app/services/pkg-status-rendering.service'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
export type PackageActionData = {
@@ -35,7 +35,7 @@ export type PackageActionData = {
id: string
title: string
icon: string
status: BaseStatus
status: PrimaryStatus
}
actionInfo: {
id: string
@@ -152,6 +152,7 @@ export class ActionInputModal {
}),
).pipe(
map(res => {
console.warn('MAP', res)
const originalValue = res.value || {}
this.eventId = res.eventId
@@ -170,6 +171,7 @@ export class ActionInputModal {
}
}),
catchError(e => {
console.error('catchError', e)
this.error = String(getErrorMessage(e))
return EMPTY
}),

View File

@@ -10,20 +10,30 @@ import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
import {
BaseStatus,
getInstalledBaseStatus,
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
const INACTIVE: PrimaryStatus[] = [
'installing',
'updating',
'removing',
'restoring',
'backing-up',
]
@Component({
template: `
@if (package(); as pkg) {
@let inactive = isInactive();
@for (group of pkg.actions | keyvalue; track $index) {
<section class="g-card">
<header>{{ group.key }}</header>
@@ -31,6 +41,7 @@ import {
<button
tuiCell
[action]="a"
[inactive]="inactive"
(click)="handle(pkg.status, pkg.icon, pkg.manifest, a)"
></button>
}
@@ -42,11 +53,13 @@ import {
<button
tuiCell
[action]="rebuild"
[inactive]="inactive"
(click)="service.rebuild(pkg.manifest.id)"
></button>
<button
tuiCell
[action]="uninstall"
[inactive]="inactive"
(click)="service.uninstall(pkg.manifest)"
></button>
</section>
@@ -75,13 +88,12 @@ export default class ServiceActionsRoute {
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
filter(pkg => pkg.stateInfo.state === 'installed'),
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
return {
status: getInstalledBaseStatus(pkg.statusInfo),
status: renderPkgStatus(pkg).primary,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
@@ -135,7 +147,7 @@ export default class ServiceActionsRoute {
}
handle(
status: BaseStatus,
status: PrimaryStatus,
icon: string,
{ id, title }: T.Manifest,
action: T.ActionMetadata & { id: string },
@@ -145,4 +157,8 @@ export default class ServiceActionsRoute {
actionInfo: { id: action.id, metadata: action },
})
}
protected readonly isInactive = computed(
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
)
}

View File

@@ -13,21 +13,9 @@ import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { TitleDirective } from 'src/app/services/title.service'
import { getManifest } from 'src/app/utils/get-package-data'
const INACTIVE: PrimaryStatus[] = [
'installing',
'updating',
'removing',
'restoring',
'backing-up',
]
@Component({
template: `
@if (service()) {
@@ -55,7 +43,7 @@ const INACTIVE: PrimaryStatus[] = [
<span tuiSubtitle>{{ manifest()?.version }}</span>
</span>
</header>
<nav [attr.inert]="isInactive() ? '' : null">
<nav>
@for (item of nav; track $index) {
@if (item.title === 'Documentation') {
<a
@@ -141,10 +129,6 @@ const INACTIVE: PrimaryStatus[] = [
mask: linear-gradient(to bottom right, black, transparent);
}
nav[inert] a:not(:first-child) {
opacity: var(--tui-disabled-opacity);
}
a a {
display: none;
}
@@ -247,9 +231,4 @@ export class ServiceOutletComponent {
protected readonly manifest = computed(
(pkg = this.service()) => pkg && getManifest(pkg),
)
protected readonly isInactive = computed(
(pkg = this.service()) =>
!pkg || INACTIVE.includes(renderPkgStatus(pkg).primary),
)
}

View File

@@ -48,7 +48,7 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
@if (status() !== 'backing-up') {
<service-health-checks [checks]="health()" />
<service-uptime class="g-card" [started]="pkg.statusInfo.started" />
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
<service-interfaces [pkg]="pkg" />
@if (errors() | async; as errors) {
<service-dependencies

View File

@@ -85,8 +85,6 @@ import { i18nPipe } from '@start9labs/shared'
</table>
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
td {
position: relative;
width: 25%;

View File

@@ -14,7 +14,7 @@ import {
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiButton, TuiHint } from '@taiga-ui/core'
import { filter, from, merge, Subject } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { SSHKey } from 'src/app/services/api/api.types'
@@ -90,6 +90,7 @@ import { SSHTableComponent } from './table.component'
TitleDirective,
i18nPipe,
DocsLinkDirective,
TuiHint,
],
})
export default class SystemSSHComponent {

View File

@@ -69,8 +69,6 @@ import { SSHKey } from 'src/app/services/api/api.types'
</table>
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
td {
position: relative;

View File

@@ -27,6 +27,7 @@ const allowedStatuses = {
'stopping',
'starting',
'backing-up',
'task-required',
]),
}

View File

@@ -1395,6 +1395,7 @@ export namespace Mock {
name: 'Color',
required: false,
default: null,
immutable: true,
}),
datetime: ISB.Value.datetime({
name: 'Datetime',
@@ -1481,6 +1482,7 @@ export namespace Mock {
description:
'<ul><li>determines whether your node is running on testnet or mainnet</li></ul><script src="fake"></script>',
warning: 'Chain will have to resync!',
immutable: true,
}),
'object-list': ISB.Value.list(
ISB.List.obj(
@@ -1598,6 +1600,8 @@ export namespace Mock {
option1: 'option1',
option2: 'option2',
option3: 'option3',
option4:
'https://qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm12345.onion',
},
disabled: ['option2'],
})),
@@ -1610,6 +1614,7 @@ export namespace Mock {
default: 7,
integer: false,
units: 'BTC',
placeholder: 'Is it 237?',
min: -100,
max: 100,
}),

View File

@@ -1089,7 +1089,7 @@ export class MockApiService extends ApiService {
health: {
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
result: 'starting',
result: 'success',
message: null,
},
'unnecessary-health-check': {

View File

@@ -47,6 +47,7 @@ export const mockPatchData: DataModel = {
addSsl: {
preferredExternalPort: 443,
alpn: { specified: ['http/1.1', 'h2'] },
addXForwardedHeaders: false,
},
secure: null,
},

View File

@@ -44,7 +44,7 @@ export interface DependencyErrorTaskRequired {
export type DependencyErrorHealthChecksFailed = {
type: 'healthChecksFailed'
check: T.NamedHealthCheckResult
check?: T.NamedHealthCheckResult
}
export type DependencyErrorTransitive = {
@@ -164,10 +164,10 @@ export class DepErrorService {
}
// health check failure
if (depStatus === 'running' && currentDep?.kind === 'running') {
if (currentDep?.kind === 'running') {
for (let id of currentDep.healthChecks) {
const check = dep.statusInfo.health[id]
if (check && check?.result !== 'success') {
if (check?.result !== 'success') {
return {
type: 'healthChecksFailed',
check,

View File

@@ -93,8 +93,12 @@ export class FormService {
}
return this.formBuilder.control(value, stringValidators(spec))
case 'textarea':
value = currentValue || null
return this.formBuilder.control(value, textareaValidators(spec))
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, stringValidators(spec))
case 'number':
if (currentValue !== undefined) {
value = currentValue
@@ -156,7 +160,7 @@ export class FormService {
// }
function stringValidators(
spec: IST.ValueSpecText | IST.ListValueSpecText,
spec: IST.ValueSpecText | IST.ValueSpecTextarea | IST.ListValueSpecText,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
@@ -173,18 +177,6 @@ function stringValidators(
return validators
}
function textareaValidators(spec: IST.ValueSpecTextarea): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (spec.required) {
validators.push(Validators.required)
}
validators.push(textLengthInRange(spec.minLength, spec.maxLength))
return validators
}
function colorValidators({ required }: IST.ValueSpecColor): ValidatorFn[] {
const validators: ValidatorFn[] = [Validators.pattern(/^#[0-9a-f]{6}$/i)]

View File

@@ -6,12 +6,9 @@ import {
MARKDOWN,
} from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom, merge, of, shareReplay, Subject } from 'rxjs'
import { merge, of, shareReplay, Subject } from 'rxjs'
import { REPORT } from 'src/app/components/backup-report.component'
import {
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { ServerNotification } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -28,16 +25,6 @@ export class NotificationService {
this.localUnreadCount$,
).pipe(shareReplay(1))
async markSeen(notifications: ServerNotifications) {
const ids = notifications.filter(n => !n.seen).map(n => n.id)
this.updateCount(-ids.length)
this.api
.markSeenNotifications({ ids })
.catch(e => this.errorService.handleError(e))
}
async markSeenAll(latestId: number) {
this.localUnreadCount$.next(0)
@@ -46,24 +33,6 @@ export class NotificationService {
.catch(e => this.errorService.handleError(e))
}
async markUnseen(notifications: ServerNotifications) {
const ids = notifications.filter(n => n.seen).map(n => n.id)
this.updateCount(ids.length)
this.api
.markUnseenNotifications({ ids })
.catch(e => this.errorService.handleError(e))
}
async remove(notifications: ServerNotifications): Promise<void> {
this.updateCount(-notifications.filter(n => !n.seen).length)
this.api
.deleteNotifications({ ids: notifications.map(n => n.id) })
.catch(e => this.errorService.handleError(e))
}
getColor(notification: ServerNotification<number>): string {
switch (notification.level) {
case 'info':
@@ -95,7 +64,6 @@ export class NotificationService {
viewModal(notification: ServerNotification<number>, full = false) {
const { data, createdAt, code, title, message } = notification
this.markSeen([notification])
if (code === 1) {
// Backup Report
@@ -116,10 +84,4 @@ export class NotificationService {
.subscribe()
}
}
private async updateCount(toAdjust: number) {
const currentCount = await firstValueFrom(this.unreadCount$)
this.localUnreadCount$.next(Math.max(currentCount + toAdjust, 0))
}
}

View File

@@ -1,6 +1,6 @@
import { inject, Injectable, InjectionToken } from '@angular/core'
import { Dump, Revision, Update } from 'patch-db-client'
import { BehaviorSubject, EMPTY, Observable } from 'rxjs'
import { BehaviorSubject, EMPTY, Observable, throwError, timer } from 'rxjs'
import {
bufferTime,
catchError,
@@ -40,7 +40,7 @@ export class PatchDbSource extends Observable<Update<DataModel>[]> {
),
),
catchError((_, original$) => {
this.state.retrigger()
this.state.retrigger(false, 2000)
return this.state.pipe(
skip(1), // skipping previous value stored due to shareReplay

View File

@@ -93,7 +93,9 @@ export class StateService extends Observable<RR.ServerState | null> {
super(subscriber => this.stream$.subscribe(subscriber))
// Retrigger on offline
this.network$.pipe(filter(v => !v)).subscribe(() => this.retrigger())
this.network$
.pipe(filter(v => !v))
.subscribe(() => this.retrigger(false, 2000))
// Show toasts
this.trigger$
@@ -109,8 +111,8 @@ export class StateService extends Observable<RR.ServerState | null> {
.subscribe()
}
retrigger(gracefully = false) {
this.trigger$.next(gracefully)
retrigger(gracefully: boolean, delay: number) {
setTimeout(() => this.trigger$.next(gracefully), delay)
}
private handleState(state: RR.ServerState): void {