mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
alpha.16 (#3068)
* 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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -549,7 +549,7 @@ export default {
|
||||
584: 'Les connexions peuvent parfois être lentes ou peu fiables',
|
||||
585: 'Public si vous partagez l’adresse 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 l’hébergement et l’accè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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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$))
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 : ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,7 +68,7 @@ export class PublicDomainService {
|
||||
required: true,
|
||||
default: null,
|
||||
patterns: [utils.Patterns.domain],
|
||||
}),
|
||||
}).map(f => f.toLocaleLowerCase()),
|
||||
...this.gatewayAndAuthoritySpec(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const allowedStatuses = {
|
||||
'stopping',
|
||||
'starting',
|
||||
'backing-up',
|
||||
'task-required',
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -47,6 +47,7 @@ export const mockPatchData: DataModel = {
|
||||
addSsl: {
|
||||
preferredExternalPort: 443,
|
||||
alpn: { specified: ['http/1.1', 'h2'] },
|
||||
addXForwardedHeaders: false,
|
||||
},
|
||||
secure: null,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user