feat: more refactors (#2844)

This commit is contained in:
Alex Inkin
2025-03-06 00:30:07 +04:00
committed by GitHub
parent 00a5fdf491
commit ac392dcb96
40 changed files with 598 additions and 577 deletions

View File

@@ -1,16 +1,16 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
IsActiveMatchOptions,
RouterLink,
RouterLinkActive,
} from '@angular/router'
ChangeDetectionStrategy,
Component,
inject,
OnInit,
viewChild,
ViewContainerRef,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { PatchDB } from 'patch-db-client'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleService } from 'src/app/services/title.service'
import { HeaderMenuComponent } from './menu.component'
import { HeaderMobileComponent } from './mobile.component'
import { HeaderNavigationComponent } from './navigation.component'
import { HeaderSnekDirective } from './snek.directive'
import { HeaderStatusComponent } from './status.component'
@@ -19,7 +19,8 @@ import { HeaderStatusComponent } from './status.component'
selector: 'header[appHeader]',
template: `
<header-navigation />
<div class="item item_center" [headerMobile]="breadcrumbs$ | async">
<div class="item item_center">
<div class="mobile"><ng-container #vcr /></div>
<img
[appSnek]="snekScore()"
class="snek"
@@ -41,6 +42,10 @@ import { HeaderStatusComponent } from './status.component'
margin: var(--bumper);
overflow: hidden;
.mobile {
display: none;
}
.item {
position: relative;
border-radius: inherit;
@@ -65,6 +70,7 @@ import { HeaderStatusComponent } from './status.component'
&_center {
flex: 1;
min-width: 0;
}
&_connection::before {
@@ -116,25 +122,36 @@ import { HeaderStatusComponent } from './status.component'
.item_center::before {
left: -2rem;
}
.mobile {
display: flex;
height: 100%;
align-items: center;
font: var(--tui-font-text-l);
padding: 1rem;
white-space: nowrap;
overflow: hidden;
::ng-deep > [tuiIconButton] {
margin-inline-start: -1rem;
}
}
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
RouterLinkActive,
AsyncPipe,
HeaderStatusComponent,
HeaderNavigationComponent,
HeaderSnekDirective,
HeaderMobileComponent,
HeaderMenuComponent,
],
})
export class HeaderComponent {
readonly options = OPTIONS
readonly breadcrumbs$ = inject(BreadcrumbsService)
export class HeaderComponent implements OnInit {
private readonly title = inject(TitleService)
readonly vcr = viewChild.required('vcr', { read: ViewContainerRef })
readonly snekScore = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$(
'ui',
@@ -144,11 +161,8 @@ export class HeaderComponent {
),
{ initialValue: 0 },
)
}
const OPTIONS: IsActiveMatchOptions = {
paths: 'exact',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
ngOnInit() {
this.title.register(this.vcr())
}
}

View File

@@ -1,16 +1,11 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import {
TuiButton,
TuiDataList,
TuiDialogService,
TuiDropdown,
TuiIcon,
} from '@taiga-ui/core'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiButton, TuiDataList, TuiDropdown, TuiIcon } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { RESOURCES } from 'src/app/utils/resources'
import { STATUS } from 'src/app/services/status.service'
import { RESOURCES } from 'src/app/utils/resources'
import { ABOUT } from './about.component'
@Component({
@@ -102,7 +97,7 @@ import { ABOUT } from './about.component'
export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService)
private readonly dialogs = inject(TuiResponsiveDialogService)
open = false

View File

@@ -1,76 +0,0 @@
import { TuiIcon } from '@taiga-ui/core'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { WA_WINDOW } from '@ng-web-apis/common'
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
@Component({
standalone: true,
selector: '[headerMobile]',
template: `
@if (headerMobile && headerMobile.length > 1) {
<a [routerLink]="back" [style.padding.rem]="0.75">
<tui-icon icon="@tui.arrow-left" />
</a>
}
<span class="title">{{ title }}</span>
<ng-content />
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
align-items: center;
font-size: 1rem;
> * {
display: none;
}
}
.title {
@include text-overflow();
max-width: calc(100% - 5rem);
text-transform: capitalize;
&:first-child {
margin-inline-start: 1rem;
}
}
:host-context(tui-root._mobile) {
> * {
display: block;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, RouterLink],
})
export class HeaderMobileComponent {
private readonly win = inject(WA_WINDOW)
@Input() headerMobile: readonly Breadcrumb[] | null = []
get title() {
return (
this.headerMobile?.[this.headerMobile?.length - 1]?.title ||
(this.win.location.search ? 'Utilities' : 'Services')
)
}
get back() {
return (
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
'/portal/services'
)
}
}

View File

@@ -7,8 +7,8 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink, RouterLinkActive } from '@angular/router'
import { TuiSheetDialogService, TuiTabBar } from '@taiga-ui/addon-mobile'
import { TuiDialogService, TuiIcon } from '@taiga-ui/core'
import { TuiResponsiveDialogService, TuiTabBar } from '@taiga-ui/addon-mobile'
import { TuiIcon } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit'
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
import { BadgeService } from 'src/app/services/badge.service'
@@ -133,8 +133,7 @@ const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace']
],
})
export class TabsComponent {
private readonly sheets = inject(TuiSheetDialogService)
private readonly dialogs = inject(TuiDialogService)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly links = viewChildren(RouterLinkActive)
index = 3
@@ -154,7 +153,7 @@ export class TabsComponent {
}
more(content: TemplateRef<any>) {
this.sheets.open(content, { label: 'Start OS' }).subscribe({
this.dialogs.open(content, { label: 'Start OS' }).subscribe({
complete: () => this.update(),
})
}

View File

@@ -1,12 +1,9 @@
import { TuiScrollbar } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { NavigationEnd, Router, RouterOutlet } from '@angular/router'
import { RouterOutlet } from '@angular/router'
import { TuiScrollbar } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderComponent } from './components/header/header.component'
@@ -48,15 +45,5 @@ import { HeaderComponent } from './components/header/header.component'
],
})
export class PortalComponent {
private readonly breadcrumbs = inject(BreadcrumbsService)
private readonly _ = inject(Router)
.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(e => {
this.breadcrumbs.update(e.url.replace('/portal/services/', ''))
})
readonly name$ = inject<PatchDB<DataModel>>(PatchDB).watch$('ui', 'name')
}

View File

@@ -4,9 +4,11 @@ import { TuiSelectModule, TuiTextfieldControllerModule } from '@taiga-ui/legacy'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
@Component({
template: `
<ng-container *title>Logs</ng-container>
<tui-select
tuiTextfieldAppearance="secondary"
tuiTextfieldSize="m"
@@ -59,6 +61,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
TuiSelectModule,
TuiTextfieldControllerModule,
LogsComponent,
TitleDirective,
],
})
export default class SystemLogsComponent {

View File

@@ -1,27 +1,27 @@
import { TuiScrollbar } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router } from '@angular/router'
import {
AbstractCategoryService,
FilterPackagesPipe,
FilterPackagesPipeModule,
} from '@start9labs/marketplace'
import { tap, withLatestFrom } from 'rxjs'
import { MarketplaceNotificationComponent } from './components/notification.component'
import { MarketplaceMenuComponent } from './components/menu.component'
import { MarketplaceTileComponent } from './components/tile.component'
import { MarketplaceControlsComponent } from './components/controls.component'
import { MarketplacePreviewComponent } from './modals/preview.component'
import { MarketplaceSidebarsComponent } from './components/sidebars.component'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { ActivatedRoute, Router } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { TuiScrollbar } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { tap, withLatestFrom } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { MarketplaceMenuComponent } from './components/menu.component'
import { MarketplaceNotificationComponent } from './components/notification.component'
import { MarketplaceSidebarsComponent } from './components/sidebars.component'
import { MarketplaceTileComponent } from './components/tile.component'
@Component({
standalone: true,
template: `
<ng-container *title>Marketplace</ng-container>
<marketplace-menu />
<tui-scrollbar>
<div class="marketplace-content-wrapper">
@@ -152,14 +152,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
MarketplaceTileComponent,
MarketplaceMenuComponent,
MarketplaceNotificationComponent,
MarketplaceControlsComponent,
MarketplacePreviewComponent,
MarketplaceSidebarsComponent,
TuiScrollbar,
FilterPackagesPipeModule,
TitleDirective,
],
})
export class MarketplaceComponent {
export default class MarketplaceComponent {
private readonly categoryService = inject(AbstractCategoryService)
private readonly marketplaceService = inject(MarketplaceService)
private readonly router = inject(Router)

View File

@@ -4,8 +4,7 @@ const MARKETPLACE_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () =>
import('./marketplace.component').then(m => m.MarketplaceComponent),
loadComponent: () => import('./marketplace.component'),
},
]

View File

@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiProgress } from '@taiga-ui/kit'
import { CpuComponent } from 'src/app/routes/portal/routes/metrics/cpu.component'
import { TitleDirective } from 'src/app/services/title.service'
import { TemperatureComponent } from 'src/app/routes/portal/routes/metrics/temperature.component'
import { MetricComponent } from 'src/app/routes/portal/routes/metrics/metric.component'
import { MetricsService } from 'src/app/routes/portal/routes/metrics/metrics.service'
@@ -12,6 +13,7 @@ import { TimeService } from 'src/app/services/time.service'
standalone: true,
selector: 'app-metrics',
template: `
<ng-container *title>Metrics</ng-container>
<section>
<app-metric class="wide" label="Storage" [style.max-height.%]="85">
<progress
@@ -168,7 +170,7 @@ import { TimeService } from 'src/app/services/time.service'
MetricComponent,
TemperatureComponent,
CpuComponent,
AsyncPipe,
TitleDirective,
],
})
export default class SystemMetricsComponent {

View File

@@ -9,10 +9,12 @@ import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core'
import { RR, 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'
import { NotificationsTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>Notifications</ng-container>
<h3 class="g-title">
<button
appearance="primary"
@@ -54,7 +56,13 @@ import { NotificationsTableComponent } from './table.component'
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiDropdown, TuiButton, TuiDataList, NotificationsTableComponent],
imports: [
TuiDropdown,
TuiButton,
TuiDataList,
NotificationsTableComponent,
TitleDirective,
],
})
export default class NotificationsComponent {
readonly service = inject(NotificationService)

View File

@@ -1,4 +1,4 @@
import { TuiLineClamp, TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
@@ -7,7 +7,6 @@ import {
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { BehaviorSubject } from 'rxjs'
import {
ServerNotification,
ServerNotifications,
@@ -76,13 +75,7 @@ import { NotificationItemComponent } from './item.component'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
FormsModule,
TuiCheckbox,
TuiLineClamp,
NotificationItemComponent,
TuiSkeleton,
],
imports: [FormsModule, TuiCheckbox, NotificationItemComponent, TuiSkeleton],
})
export class NotificationsTableComponent implements OnChanges {
@Input() notifications?: ServerNotifications

View File

@@ -1,69 +1,99 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
inject,
Input,
input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { TuiButton } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { ActionService } from 'src/app/services/action.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
standalone: true,
selector: 'button[actionRequest]',
selector: 'tr[actionRequest]',
template: `
<span tuiTitle>
<strong tuiFade><ng-content /></strong>
<span tuiSubtitle>
{{ actionRequest.reason || 'No reason provided' }}
</span>
</span>
<td>
<tui-avatar size="xs"><img [src]="pkg().icon" alt="" /></tui-avatar>
<span>{{ title() }}</span>
</td>
<td>
@if (actionRequest().severity === 'critical') {
<strong [style.color]="'var(--tui-status-warning)'">Required</strong>
} @else {
<strong [style.color]="'var(--tui-status-info)'">Optional</strong>
}
</td>
<td
[style.color]="'var(--tui-text-secondary)'"
[style.grid-area]="'2 / span 2'"
>
{{ actionRequest().reason || 'No reason provided' }}
</td>
<td>
<button tuiButton (click)="handle()">
{{ pkg().actions[actionRequest().actionId].name }}
</button>
</td>
`,
styles: `
:host {
width: 100%;
margin: 0 -1rem;
td:first-child {
white-space: nowrap;
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
strong {
white-space: nowrap;
td:last-child {
text-align: right;
grid-area: span 2;
}
span {
margin-inline-start: 0.5rem;
vertical-align: middle;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
td {
padding: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle, TuiFade],
hostDirectives: [TuiCell],
imports: [TuiButton, TuiAvatar],
})
export class ServiceActionRequestComponent {
private readonly actionService = inject(ActionService)
@Input({ required: true })
actionRequest!: T.ActionRequest
readonly actionRequest = input.required<T.ActionRequest>()
readonly services = input.required<Record<string, PackageDataEntry>>()
@Input({ required: true })
pkg!: PackageDataEntry
@HostListener('click')
async handleAction() {
const { title } = getManifest(this.pkg)
const { actionId, packageId } = this.actionRequest
readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
readonly title = computed(() => getManifest(this.pkg()).title)
async handle() {
this.actionService.present({
pkgInfo: {
id: packageId,
title,
mainStatus: this.pkg.status.main,
icon: this.pkg.icon,
id: this.actionRequest().packageId,
title: this.title(),
mainStatus: this.pkg().status.main,
icon: this.pkg().icon,
},
actionInfo: {
id: actionId,
metadata: this.pkg.actions[actionId],
id: this.actionRequest().actionId,
metadata: this.pkg().actions[this.actionRequest().actionId],
},
requestInfo: this.actionRequest,
requestInfo: this.actionRequest(),
})
}
}

View File

@@ -4,73 +4,57 @@ import {
computed,
input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiTable } from '@taiga-ui/addon-table'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionRequestComponent } from './action-request.component'
type ActionRequest = T.ActionRequest & {
actionName: string
}
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({
standalone: true,
selector: 'service-action-requests',
template: `
@for (request of requests().critical; track $index) {
<button [actionRequest]="request" [pkg]="pkg()">
{{ request.actionName }}
<small class="g-warning">Required</small>
</button>
}
@for (request of requests().important; track $index) {
<button [actionRequest]="request" [pkg]="pkg()">
{{ request.actionName }}
<small class="g-info">Requested</small>
</button>
}
@if (requests().critical.length + requests().important.length === 0) {
<blockquote>No pending tasks</blockquote>
<header>Tasks</header>
<table tuiTable class="g-table">
<thead>
<tr>
<th tuiTh>Service</th>
<th tuiTh>Type</th>
<th tuiTh>Description</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@for (item of requests(); track $index) {
<tr [actionRequest]="item.request" [services]="services()"></tr>
}
</tbody>
</table>
@if (!requests().length) {
<service-placeholder icon="@tui.list-checks">
All tasks complete
</service-placeholder>
}
`,
styles: `
small {
margin-inline-start: 0.25rem;
padding-inline-start: 0.5rem;
box-shadow: inset 1px 0 var(--tui-border-normal);
}
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
:host {
grid-column: span 6;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceActionRequestComponent],
imports: [
TuiTable,
ServiceActionRequestComponent,
ServicePlaceholderComponent,
],
})
export class ServiceActionRequestsComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly requests = computed(() => {
const { id } = getManifest(this.pkg())
const critical: ActionRequest[] = []
const important: ActionRequest[] = []
readonly services = input.required<Record<string, PackageDataEntry>>()
readonly requests = computed(() =>
Object.values(this.pkg().requestedActions)
.filter(r => r.active && r.request.packageId === id)
.forEach(r => {
const action = {
...r.request,
actionName: this.pkg().actions[r.request.actionId].name,
}
if (r.request.severity === 'critical') {
critical.push(action)
} else {
important.push(action)
}
})
return { critical, important }
})
.filter(r => r.active)
.sort((a, b) => a.request.severity.localeCompare(b.request.severity)),
)
}

View File

@@ -56,8 +56,22 @@ import { getManifest } from 'src/app/utils/get-package-data'
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 1rem;
justify-content: center;
inline-size: 20rem;
max-inline-size: 100%;
margin-block-start: 1rem;
}
:host-context(tui-root._mobile) {
display: flex;
margin: 0;
inline-size: min-content;
[tuiButton] {
font-size: 0;
gap: 0;
border-radius: 100%;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -5,7 +5,7 @@ import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServiceActionRequestsComponent } from './action-requests.component'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({
selector: 'service-dependencies',
@@ -24,33 +24,15 @@ import { ServiceActionRequestsComponent } from './action-requests.component'
</span>
<tui-icon icon="@tui.arrow-right" />
</a>
@if (services[d.key]; as service) {
<service-action-requests [pkg]="service" />
}
} @empty {
<blockquote>No dependencies</blockquote>
<service-placeholder icon="@tui.boxes">
No dependencies
</service-placeholder>
}
`,
styles: `
a {
margin: 0 -1rem;
&::after {
display: none;
}
}
service-action-requests {
display: block;
padding: 1rem 0 0 2.375rem;
margin: -1rem 0 1rem 1.125rem;
box-shadow: inset 0.125rem 0 var(--tui-border-normal);
}
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
:host {
grid-column: span 3;
}
`,
host: { class: 'g-card' },
@@ -58,12 +40,12 @@ import { ServiceActionRequestsComponent } from './action-requests.component'
standalone: true,
imports: [
KeyValuePipe,
RouterLink,
TuiCell,
TuiAvatar,
TuiTitle,
ServiceActionRequestsComponent,
RouterLink,
TuiIcon,
ServicePlaceholderComponent,
],
})
export class ServiceDependenciesComponent {

View File

@@ -54,7 +54,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
styles: `
:host {
grid-column: span 2;
grid-column: span 4;
}
header {

View File

@@ -1,62 +1,61 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
@Component({
selector: 'service-health-check',
standalone: true,
selector: 'tr[healthCheck]',
template: `
@if (loading) {
<tui-loader [tuiSkeleton]="!connected" [inheritColor]="!check.result" />
} @else {
<tui-icon
[icon]="icon"
[tuiSkeleton]="!connected"
[style.color]="color"
/>
}
<span tuiTitle>
<strong [tuiSkeleton]="!connected && 2">
{{ connected ? check.name : '' }}
</strong>
<span tuiSubtitle [tuiSkeleton]="!connected && 3" [style.color]="color">
{{ connected ? message : '' }}
<td>{{ healthCheck.name }}</td>
<td>
<span>
@if (loading) {
<tui-loader size="m" />
} @else {
<tui-icon [icon]="icon" [style.color]="color" />
}
{{ message }}
</span>
</span>
</td>
`,
styles: [
`
:first-letter {
text-transform: uppercase;
span {
display: flex;
align-items: center;
gap: 0.5rem;
}
tui-loader {
width: 1.5rem;
height: 1.5rem;
:host-context(tui-root._mobile) {
display: flex;
flex-direction: column;
td:first-child {
font-weight: bold;
padding-bottom: 0;
}
td:last-child {
color: var(--tui-text-secondary);
}
}
`,
],
hostDirectives: [TuiCell],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiLoader, TuiIcon, TuiTitle, TuiSkeleton],
imports: [TuiLoader, TuiIcon],
})
export class ServiceHealthCheckComponent {
@Input({ required: true })
check!: T.NamedHealthCheckResult
@Input()
connected = false
healthCheck!: T.NamedHealthCheckResult
get loading(): boolean {
const { result } = this.check
const { result } = this.healthCheck
return !result || result === 'starting' || result === 'loading'
}
get icon(): string {
switch (this.check.result) {
switch (this.healthCheck.result) {
case 'success':
return '@tui.check'
case 'failure':
@@ -67,7 +66,7 @@ export class ServiceHealthCheckComponent {
}
get color(): string {
switch (this.check.result) {
switch (this.healthCheck.result) {
case 'success':
return 'var(--tui-status-positive)'
case 'failure':
@@ -82,21 +81,21 @@ export class ServiceHealthCheckComponent {
}
get message(): string {
if (!this.check.result) {
if (!this.healthCheck.result) {
return 'Awaiting result...'
}
switch (this.check.result) {
switch (this.healthCheck.result) {
case 'starting':
return 'Starting...'
case 'success':
return `Success: ${this.check.message}`
return `Success: ${this.healthCheck.message}`
case 'loading':
case 'failure':
return this.check.message
return this.healthCheck.message
// disabled
default:
return this.check.result
return this.healthCheck.result
}
}
}

View File

@@ -1,42 +1,42 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { ServiceHealthCheckComponent } from 'src/app/routes/portal/routes/services/components/health-check.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { TuiTable } from '@taiga-ui/addon-table'
import { ServiceHealthCheckComponent } from './health-check.component'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({
standalone: true,
selector: 'service-health-checks',
template: `
<header>Health Checks</header>
@for (check of checks; track $index) {
<service-health-check
[check]="check"
[connected]="!!(connected$ | async)"
/>
} @empty {
<blockquote>No health checks</blockquote>
<table tuiTable class="g-table">
<thead>
<tr>
<th tuiTh>Name</th>
<th tuiTh>Status</th>
</tr>
</thead>
<tbody>
@for (check of checks(); track $index) {
<tr [healthCheck]="check"></tr>
}
</tbody>
</table>
@if (!checks().length) {
<service-placeholder icon="@tui.heart-pulse">
No health checks
</service-placeholder>
}
`,
styles: `
blockquote {
text-align: center;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
:host {
grid-column: span 3;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, ServiceHealthCheckComponent],
imports: [ServiceHealthCheckComponent, ServicePlaceholderComponent, TuiTable],
})
export class ServiceHealthChecksComponent {
@Input({ required: true })
checks: readonly T.NamedHealthCheckResult[] = []
readonly connected$ = inject(ConnectionService)
readonly checks = input.required<readonly T.NamedHealthCheckResult[]>()
}

View File

@@ -25,8 +25,10 @@ import { MappedInterface } from '../types/mapped-interface'
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
</td>
<td class="g-secondary">{{ info.description }}</td>
<td class="hosting">
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
<td>
@if (info.public) {
<button
tuiButton
@@ -49,10 +51,10 @@ import { MappedInterface } from '../types/mapped-interface'
</button>
}
</td>
<td>
<td [style.grid-area]="'span 2'">
@if (info.type === 'ui') {
<a
tuiButton
tuiIconButton
appearance="action"
iconStart="@tui.external-link"
target="_blank"
@@ -76,20 +78,15 @@ import { MappedInterface } from '../types/mapped-interface'
text-transform: uppercase;
}
.hosting {
white-space: nowrap;
}
:host-context(tui-root._mobile) {
display: block;
padding: 0.5rem 0;
display: grid;
grid-template-columns: repeat(3, min-content) 1fr 2rem;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
td {
display: inline-block;
}
.hosting {
font-size: 0;
padding: 0;
}
}
`,

View File

@@ -27,23 +27,21 @@ import { ServiceInterfaceComponent } from './interface.component'
<th tuiTh></th>
</tr>
</thead>
@for (info of interfaces(); track $index) {
<tr
serviceInterface
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
></tr>
}
<tbody>
@for (info of interfaces(); track $index) {
<tr
serviceInterface
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
></tr>
}
</tbody>
</table>
`,
styles: `
:host {
grid-column: span 2;
}
table {
margin: 0 -0.5rem;
grid-column: span 4;
}
`,
host: { class: 'g-card' },

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiIcon } from '@taiga-ui/core'
@Component({
standalone: true,
selector: 'service-placeholder',
template: '<tui-icon [icon]="icon()" /><ng-content/>',
styles: `
:host {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
tui-icon {
font-size: 2.5rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon],
})
export class ServicePlaceholderComponent {
readonly icon = input.required<string>()
}

View File

@@ -1,16 +1,9 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
HostBinding,
Input,
} from '@angular/core'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiLoader } from '@taiga-ui/core'
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
import {
PrimaryRendering,
PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@@ -18,20 +11,25 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
selector: 'service-status',
template: `
<header>Status</header>
<div [class]="class">
<div>
@if (installingInfo) {
<strong>
<h3>
<tui-loader size="s" [inheritColor]="true" />
Installing
<span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }}
</strong>
</h3>
} @else {
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" />
{{ connected ? rendering.display : 'Unknown' }}
@if (rendering.showDots) {
<span class="loading-dots"></span>
}
<h3 [class]="class">
{{ text }}
@if (text === 'Action Required') {
<small>See below</small>
}
@if (rendering.showDots) {
<span class="loading-dots"></span>
}
</h3>
}
<ng-content />
</div>
@@ -39,17 +37,29 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
styles: [
`
:host {
display: grid;
grid-template-rows: min-content 1fr;
align-items: center;
font: var(--tui-font-heading-6);
text-align: center;
grid-column: span 2;
}
status {
display: grid;
grid-template-rows: min-content 1fr 1fr;
h3 {
font: var(--tui-font-heading-4);
font-weight: normal;
margin: 0;
}
div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 1rem 0;
}
small {
display: block;
font: var(--tui-font-text-l);
color: var(--tui-text-secondary);
text-align: center;
}
tui-loader {
@@ -57,12 +67,24 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
vertical-align: bottom;
margin: 0 0.25rem -0.125rem 0;
}
:host-context(tui-root._mobile) {
div {
flex-direction: row;
justify-content: space-between;
padding: 0.5rem 0;
}
small {
text-align: left;
}
}
`,
],
host: { class: 'g-card' },
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InstallingProgressDisplayPipe, TuiIcon, TuiLoader],
imports: [InstallingProgressDisplayPipe, TuiLoader],
})
export class ServiceStatusComponent {
@Input({ required: true })
@@ -74,6 +96,10 @@ export class ServiceStatusComponent {
@Input()
connected = false
get text() {
return this.connected ? this.rendering.display : 'Unknown'
}
get class(): string | null {
if (!this.connected) return null
@@ -94,21 +120,4 @@ export class ServiceStatusComponent {
get rendering() {
return PrimaryRendering[this.status]
}
get icon(): string {
if (!this.connected) return '@tui.circle'
switch (this.rendering.color) {
case 'danger':
return '@tui.circle-x'
case 'warning':
return '@tui.circle-alert'
case 'success':
return '@tui.circle-check'
case 'primary':
return '@tui.circle-minus'
default:
return '@tui.circle'
}
}
}

View File

@@ -5,6 +5,7 @@ import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getInstalledPrimaryStatus } 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'
import { ServiceComponent } from './service.component'
import { ServicesService } from './services.service'
@@ -12,6 +13,7 @@ import { ServicesService } from './services.service'
@Component({
standalone: true,
template: `
<ng-container *title>Services</ng-container>
<table tuiTable class="g-table" [(sorter)]="sorter">
<thead>
<tr>
@@ -55,7 +57,7 @@ import { ServicesService } from './services.service'
}
`,
host: { class: 'g-page' },
imports: [ServiceComponent, ToManifestPipe, TuiTable],
imports: [ServiceComponent, ToManifestPipe, TuiTable, TitleDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class DashboardComponent {

View File

@@ -41,7 +41,7 @@ import ServiceMarkdownRoute from './markdown.component'
display: flex;
flex-direction: column;
max-width: 32rem;
padding: 0.75rem;
padding: 0.5rem 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -55,14 +55,6 @@ const OTHER = 'Other Custom Actions'
flex-direction: column;
margin-bottom: 2rem;
}
[tuiCell] {
margin: 0 -1rem;
&:last-child {
margin-bottom: -0.75rem;
}
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,53 +1,65 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { getAddresses } from '../../../components/interfaces/interface.utils'
@Component({
template: `
<app-interface
*ngIf="interfacesWithAddresses$ | async as serviceInterface"
[packageId]="context.packageId"
[serviceInterface]="serviceInterface"
/>
<ng-container *title>
<a routerLink="../.." tuiIconButton iconStart="@tui.arrow-left">Back</a>
{{ interface()?.name }}
</ng-container>
@if (interface(); as serviceInterface) {
<app-interface
[packageId]="pkgId"
[serviceInterface]="serviceInterface"
/>
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfaceComponent],
imports: [InterfaceComponent, RouterLink, TuiButton, TitleDirective],
})
export default class ServiceInterfaceRoute {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly config = inject(ConfigService)
readonly context = {
packageId: getPkgId(),
interfaceId:
inject(ActivatedRoute).snapshot.paramMap.get('interfaceId') || '',
}
readonly pkgId = getPkgId()
readonly interfaceId = input('')
readonly interfacesWithAddresses$ = combineLatest([
this.patch.watch$(
'packageData',
this.context.packageId,
'serviceInterfaces',
this.context.interfaceId,
),
this.patch.watch$('packageData', this.context.packageId, 'hosts'),
]).pipe(
map(([iFace, hosts]) => ({
...iFace,
addresses: getAddresses(
iFace,
hosts[iFace.addressInfo.hostId],
this.config,
),
})),
readonly pkg = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
)
readonly interface = computed(() => {
const pkg = this.pkg()
const id = this.interfaceId()
if (!pkg || !id) {
return
}
const { serviceInterfaces, hosts } = pkg
const item = serviceInterfaces[this.interfaceId()]
const host = hosts[item.addressInfo.hostId]
return {
...item,
public: host.bindings[item.addressInfo.internalPort].net.public,
addresses: getAddresses(item, host, this.config),
}
})
}

View File

@@ -6,12 +6,13 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { TuiAppearance, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar, TuiFade } from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { getManifest } from 'src/app/utils/get-package-data'
const ICONS = {
@@ -25,6 +26,13 @@ const ICONS = {
@Component({
template: `
@if (service()) {
<div *title class="title">
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
<tui-avatar size="xs" [style.margin-inline-end.rem]="0.75">
<img alt="" [src]="service()?.icon" />
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
</div>
<aside>
<header tuiCell>
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
@@ -58,6 +66,16 @@ const ICONS = {
padding: 0;
}
.title {
display: flex;
align-items: center;
margin-inline-start: -1rem;
&:not(:only-child) {
display: none;
}
}
aside {
position: sticky;
top: 1px;
@@ -108,10 +126,13 @@ const ICONS = {
flex: 1;
justify-content: center;
border-radius: 0;
padding: 0.125rem;
background: var(--tui-background-neutral-1);
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
&.active {
background: none;
box-shadow: none;
}
[tuiTitle] {
@@ -131,6 +152,8 @@ const ICONS = {
TuiAppearance,
TuiIcon,
TuiFade,
TitleDirective,
TuiButton,
],
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
})

View File

@@ -46,11 +46,7 @@ import { ServiceStatusComponent } from '../components/status.component'
<service-interfaces [pkg]="pkg()" [disabled]="status() !== 'running'" />
<service-dependencies [pkg]="pkg()" [services]="services()" />
<service-health-checks [checks]="health()" />
<section class="g-card">
<header>Tasks</header>
<service-action-requests [pkg]="pkg()" />
</section>
<service-action-requests [pkg]="pkg()" [services]="services()" />
}
@if (installing()) {
@@ -65,7 +61,7 @@ import { ServiceStatusComponent } from '../components/status.component'
styles: `
:host {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: max-content;
gap: 1rem;
}
@@ -79,7 +75,7 @@ import { ServiceStatusComponent } from '../components/status.component'
grid-template-columns: 1fr;
> * {
grid-column: span 1 !important;
grid-column: span 1;
}
}
`,

View File

@@ -5,6 +5,7 @@ import {
ReactiveFormsModule,
UntypedFormGroup,
} from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { IST, inputSpec } from '@start9labs/start-sdk'
import { TuiButton, TuiDialogService } from '@taiga-ui/core'
@@ -15,11 +16,16 @@ import { FormModule } from 'src/app/routes/portal/components/form/form.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { EmailInfoComponent } from './info.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Email
</ng-container>
<email-info />
<ng-container *ngIf="form$ | async as form">
<form [formGroup]="form" [style.text-align]="'right'">
@@ -31,7 +37,7 @@ import { EmailInfoComponent } from './info.component'
<button
*ngIf="isSaved"
tuiButton
appearance="destructive"
appearance="secondary-destructive"
[style.margin-top.rem]="1"
[style.margin-right.rem]="1"
(click)="save(null)"
@@ -79,6 +85,8 @@ import { EmailInfoComponent } from './info.component'
TuiButton,
TuiInputModule,
EmailInfoComponent,
RouterLink,
TitleDirective,
],
})
export class SettingsEmailComponent {

View File

@@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { Observable, map } from 'rxjs'
import {
@@ -10,6 +12,7 @@ import {
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
const iface: T.ServiceInterface = {
id: '',
@@ -30,6 +33,10 @@ const iface: T.ServiceInterface = {
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Web Addresses
</ng-container>
<app-interface
*ngIf="ui$ | async as ui"
[style.max-width.rem]="50"
@@ -38,7 +45,13 @@ const iface: T.ServiceInterface = {
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfaceComponent],
imports: [
CommonModule,
InterfaceComponent,
RouterLink,
TuiButton,
TitleDirective,
],
})
export class StartOsUiComponent {
private readonly config = inject(ConfigService)

View File

@@ -1,3 +1,4 @@
import { RouterLink } from '@angular/router'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
@@ -6,10 +7,15 @@ import { ErrorService, LoadingService } from '@start9labs/shared'
import { from, map, merge, Observable, Subject } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Session } from 'src/app/services/api/api.types'
import { TitleDirective } from 'src/app/services/title.service'
import { SSHTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Active Sessions
</ng-container>
<h3 class="g-title">Current session</h3>
<table
class="g-table"
@@ -36,7 +42,14 @@ import { SSHTableComponent } from './table.component'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButton, SSHTableComponent, TuiLet],
imports: [
CommonModule,
TuiButton,
SSHTableComponent,
TuiLet,
RouterLink,
TitleDirective,
],
})
export class SettingsSessionsComponent {
private readonly loader = inject(LoadingService)

View File

@@ -1,14 +1,20 @@
import { RouterLink } from '@angular/router'
import { TuiButton } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService } from '@start9labs/shared'
import { catchError, defer, of } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SSHInfoComponent } from './info.component'
import { SSHTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
SSH
</ng-container>
<ssh-info />
<h3 class="g-title">
Saved Keys
@@ -25,7 +31,14 @@ import { SSHTableComponent } from './table.component'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButton, SSHTableComponent, SSHInfoComponent],
imports: [
CommonModule,
TuiButton,
SSHTableComponent,
SSHInfoComponent,
RouterLink,
TitleDirective,
],
})
export class SettingsSSHComponent {
private readonly errorService = inject(ErrorService)

View File

@@ -6,6 +6,7 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ErrorService, LoadingService, pauseFor } from '@start9labs/shared'
import {
TuiAlertService,
@@ -25,6 +26,7 @@ import {
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { WifiInfoComponent } from './info.component'
import { WifiTableComponent } from './table.component'
import { parseWifi, WifiData, WiFiForm } from './utils'
@@ -32,6 +34,10 @@ import { wifiSpec } from './wifi.const'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
WiFi
</ng-container>
<wifi-info />
@if (status()?.interface) {
<h3 class="g-title">
@@ -87,6 +93,8 @@ import { wifiSpec } from './wifi.const'
TuiAppearance,
WifiInfoComponent,
WifiTableComponent,
TitleDirective,
RouterLink,
],
})
export class SettingsWifiComponent {

View File

@@ -1,10 +1,12 @@
import { TuiIcon } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterModule } from '@angular/router'
import { TitleDirective } from 'src/app/services/title.service'
import { SettingsMenuComponent } from './components/menu.component'
@Component({
template: `
<ng-container *title><span>Settings</span></ng-container>
<a
routerLink="/portal/settings"
routerLinkActive="_current"
@@ -25,6 +27,7 @@ import { SettingsMenuComponent } from './components/menu.component'
}
a,
span:not(:last-child),
settings-menu {
display: none;
}
@@ -39,6 +42,6 @@ import { SettingsMenuComponent } from './components/menu.component'
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [RouterModule, TuiIcon, SettingsMenuComponent],
imports: [RouterModule, TuiIcon, SettingsMenuComponent, TitleDirective],
})
export class SettingsComponent {}

View File

@@ -8,18 +8,20 @@ import {
import { FormsModule } from '@angular/forms'
import { T } from '@start9labs/start-sdk'
import { tuiIsString } from '@taiga-ui/cdk'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiButton } from '@taiga-ui/core'
import {
TuiAvatar,
TuiFiles,
tuiInputFilesOptionsProvider,
} from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component'
import { parseS9pk } from './sideload.utils'
@Component({
template: `
<ng-container *title>Sideload</ng-container>
@if (file && package()) {
<sideload-package [package]="package()!" [file]="file!">
<button
@@ -82,10 +84,10 @@ import { parseS9pk } from './sideload.utils'
imports: [
FormsModule,
TuiFiles,
TuiLink,
TuiAvatar,
TuiButton,
SideloadPackageComponent,
TitleDirective,
],
})
export default class SideloadComponent {

View File

@@ -40,6 +40,7 @@ const routes: Routes = [
paramsInheritanceStrategy: 'always',
preloadingStrategy: PreloadAllModules,
initialNavigation: 'disabled',
bindToComponentInputs: true,
}),
],
exports: [RouterModule],

View File

@@ -191,6 +191,7 @@ export const mockPatchData: DataModel = {
backupProgress: {},
},
hostname: 'random-words',
// @ts-ignore
host: {
bindings: {
80: {

View File

@@ -1,85 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject } from 'rxjs'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { SYSTEM_UTILITIES } from 'src/app/utils/system-utilities'
import { toRouterLink } from 'src/app/utils/to-router-link'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
export interface Breadcrumb {
title: string
routerLink: string
subtitle?: string
icon?: string
}
@Injectable({
providedIn: 'root',
})
export class BreadcrumbsService extends BehaviorSubject<readonly Breadcrumb[]> {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
constructor() {
super([])
}
async update(page: string) {
const packages = await getAllPackages(this.patch)
try {
this.next(toBreadcrumbs(page.split('?')[0], packages))
} catch (e) {
this.next([])
}
}
}
function toBreadcrumbs(
id: string,
packages: Record<string, PackageDataEntry> = {},
): Breadcrumb[] {
if (id.startsWith('/portal/') && !id.startsWith('/portal/services/')) {
const [page, ...path] = id.replace('/portal/', '').split('/')
const service = `/portal/${page}`
const { icon, title } = SYSTEM_UTILITIES[service]
const breadcrumbs: Breadcrumb[] = [
{
icon,
title,
routerLink: toRouterLink(service),
},
]
if (path.length) {
breadcrumbs.push({
title: path.join(': '),
routerLink: breadcrumbs[0].routerLink + '/' + path.join('/'),
})
}
return breadcrumbs
}
const [service, ...path] = id.split('/')
const { title, version } = getManifest(packages[service])
const breadcrumbs: Breadcrumb[] = [
{
icon: packages[service].icon,
title,
subtitle: version,
routerLink: toRouterLink(service),
},
]
if (path.length) {
breadcrumbs.push({
title: path.join(': '),
routerLink: breadcrumbs[0].routerLink + '/' + path.join('/'),
})
}
return breadcrumbs
}

View File

@@ -0,0 +1,45 @@
import {
Directive,
EmbeddedViewRef,
inject,
Injectable,
OnDestroy,
OnInit,
TemplateRef,
ViewContainerRef,
} from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
@Injectable({
providedIn: 'root',
})
export class TitleService {
private vcr?: ViewContainerRef
register(vcr: ViewContainerRef) {
this.vcr = vcr
}
add(template: TemplateRef<any>) {
return this.vcr?.createEmbeddedView(template)
}
}
@Directive({
standalone: true,
selector: 'ng-template[title]',
providers: [tuiButtonOptionsProvider({ appearance: '' })],
})
export class TitleDirective implements OnInit, OnDestroy {
private readonly template = inject(TemplateRef)
private readonly service = inject(TitleService)
private view?: EmbeddedViewRef<any>
ngOnInit() {
this.view = this.service.add(this.template)
}
ngOnDestroy() {
this.view?.destroy()
}
}

View File

@@ -69,16 +69,14 @@ hr {
height: 100%;
min-height: fit-content;
flex: 1;
padding: 2rem;
tui-root._mobile & {
padding: 1rem;
}
padding: 1rem;
}
.g-card {
transition: all 300ms ease-in-out;
padding: 1.25rem 1.5rem;
position: relative;
display: flex;
flex-direction: column;
padding: 3.25rem 1rem 0.5rem;
border-radius: 0.5rem;
overflow: hidden;
background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent);
@@ -103,33 +101,38 @@ hr {
inset 0 1px rgba(255, 255, 255, 0.15),
inset 0 0 1rem rgba(0, 0, 0, 0.25);
&:hover {
box-shadow:
0 0.375rem 0.5rem rgba(0, 0, 0, 0.25),
0 -0.125rem 0.25rem rgba(55, 155, 255, 0.08),
0 0 0.5rem rgba(0, 0, 0, 0.3),
inset 0 -0.125rem rgba(255, 255, 255, 0.03),
inset 0 2px rgba(255, 255, 255, 0.1),
inset 0 1px rgba(255, 255, 255, 0.15),
inset 0 0 1rem rgba(0, 0, 0, 0.25);
> [tuiCell] {
margin: 0 -0.5rem;
&:not(:last-child)::after {
content: '';
position: absolute;
top: 100%;
left: 1rem;
right: 1rem;
height: 1px;
background: var(--tui-border-normal);
}
}
> [tuiCell]:not(:last-child)::after {
content: '';
position: absolute;
top: 100%;
left: 1rem;
right: 1rem;
height: 1px;
background: var(--tui-border-normal);
> table {
margin: 0 -0.5rem;
td:empty,
th:empty {
display: none;
}
}
> header {
padding-bottom: 0.75rem;
margin: -0.5rem 0 0.5rem;
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 0.5rem 1rem;
background: var(--tui-background-neutral-1);
box-shadow: 0 -10rem 0 10rem var(--tui-background-neutral-1);
font: var(--tui-font-heading-6);
font: var(--tui-font-text-l);
font-weight: bold;
}
}
@@ -198,6 +201,10 @@ hr {
.g-table[tuiTable] {
width: stretch;
&:not(:has(tbody tr)) {
display: none;
}
tr:not(:last-child) {
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
}