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

View File

@@ -1,16 +1,11 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
TuiButton, import { TuiButton, TuiDataList, TuiDropdown, TuiIcon } from '@taiga-ui/core'
TuiDataList,
TuiDialogService,
TuiDropdown,
TuiIcon,
} from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.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 { STATUS } from 'src/app/services/status.service'
import { RESOURCES } from 'src/app/utils/resources'
import { ABOUT } from './about.component' import { ABOUT } from './about.component'
@Component({ @Component({
@@ -102,7 +97,7 @@ import { ABOUT } from './about.component'
export class HeaderMenuComponent { export class HeaderMenuComponent {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly auth = inject(AuthService) private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiResponsiveDialogService)
open = false 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' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink, RouterLinkActive } from '@angular/router' import { RouterLink, RouterLinkActive } from '@angular/router'
import { TuiSheetDialogService, TuiTabBar } from '@taiga-ui/addon-mobile' import { TuiResponsiveDialogService, TuiTabBar } from '@taiga-ui/addon-mobile'
import { TuiDialogService, TuiIcon } from '@taiga-ui/core' import { TuiIcon } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit' import { TuiBadgeNotification } from '@taiga-ui/kit'
import { ABOUT } from 'src/app/routes/portal/components/header/about.component' import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
import { BadgeService } from 'src/app/services/badge.service' import { BadgeService } from 'src/app/services/badge.service'
@@ -133,8 +133,7 @@ const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace']
], ],
}) })
export class TabsComponent { export class TabsComponent {
private readonly sheets = inject(TuiSheetDialogService) private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly dialogs = inject(TuiDialogService)
private readonly links = viewChildren(RouterLinkActive) private readonly links = viewChildren(RouterLinkActive)
index = 3 index = 3
@@ -154,7 +153,7 @@ export class TabsComponent {
} }
more(content: TemplateRef<any>) { more(content: TemplateRef<any>) {
this.sheets.open(content, { label: 'Start OS' }).subscribe({ this.dialogs.open(content, { label: 'Start OS' }).subscribe({
complete: () => this.update(), complete: () => this.update(),
}) })
} }

View File

@@ -1,12 +1,9 @@
import { TuiScrollbar } from '@taiga-ui/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { RouterOutlet } from '@angular/router'
import { NavigationEnd, Router, RouterOutlet } from '@angular/router' import { TuiScrollbar } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component' 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 { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderComponent } from './components/header/header.component' import { HeaderComponent } from './components/header/header.component'
@@ -48,15 +45,5 @@ import { HeaderComponent } from './components/header/header.component'
], ],
}) })
export class PortalComponent { 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') 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 { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
@Component({ @Component({
template: ` template: `
<ng-container *title>Logs</ng-container>
<tui-select <tui-select
tuiTextfieldAppearance="secondary" tuiTextfieldAppearance="secondary"
tuiTextfieldSize="m" tuiTextfieldSize="m"
@@ -59,6 +61,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
TuiSelectModule, TuiSelectModule,
TuiTextfieldControllerModule, TuiTextfieldControllerModule,
LogsComponent, LogsComponent,
TitleDirective,
], ],
}) })
export default class SystemLogsComponent { export default class SystemLogsComponent {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { TuiProgress } from '@taiga-ui/kit' import { TuiProgress } from '@taiga-ui/kit'
import { CpuComponent } from 'src/app/routes/portal/routes/metrics/cpu.component' 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 { TemperatureComponent } from 'src/app/routes/portal/routes/metrics/temperature.component'
import { MetricComponent } from 'src/app/routes/portal/routes/metrics/metric.component' import { MetricComponent } from 'src/app/routes/portal/routes/metrics/metric.component'
import { MetricsService } from 'src/app/routes/portal/routes/metrics/metrics.service' 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, standalone: true,
selector: 'app-metrics', selector: 'app-metrics',
template: ` template: `
<ng-container *title>Metrics</ng-container>
<section> <section>
<app-metric class="wide" label="Storage" [style.max-height.%]="85"> <app-metric class="wide" label="Storage" [style.max-height.%]="85">
<progress <progress
@@ -168,7 +170,7 @@ import { TimeService } from 'src/app/services/time.service'
MetricComponent, MetricComponent,
TemperatureComponent, TemperatureComponent,
CpuComponent, CpuComponent,
AsyncPipe, TitleDirective,
], ],
}) })
export default class SystemMetricsComponent { 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 { RR, ServerNotifications } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { NotificationService } from 'src/app/services/notification.service' import { NotificationService } from 'src/app/services/notification.service'
import { TitleDirective } from 'src/app/services/title.service'
import { NotificationsTableComponent } from './table.component' import { NotificationsTableComponent } from './table.component'
@Component({ @Component({
template: ` template: `
<ng-container *title>Notifications</ng-container>
<h3 class="g-title"> <h3 class="g-title">
<button <button
appearance="primary" appearance="primary"
@@ -54,7 +56,13 @@ import { NotificationsTableComponent } from './table.component'
host: { class: 'g-page' }, host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiDropdown, TuiButton, TuiDataList, NotificationsTableComponent], imports: [
TuiDropdown,
TuiButton,
TuiDataList,
NotificationsTableComponent,
TitleDirective,
],
}) })
export default class NotificationsComponent { export default class NotificationsComponent {
readonly service = inject(NotificationService) 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -7,7 +7,6 @@ import {
signal, signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { BehaviorSubject } from 'rxjs'
import { import {
ServerNotification, ServerNotification,
ServerNotifications, ServerNotifications,
@@ -76,13 +75,7 @@ import { NotificationItemComponent } from './item.component'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [FormsModule, TuiCheckbox, NotificationItemComponent, TuiSkeleton],
FormsModule,
TuiCheckbox,
TuiLineClamp,
NotificationItemComponent,
TuiSkeleton,
],
}) })
export class NotificationsTableComponent implements OnChanges { export class NotificationsTableComponent implements OnChanges {
@Input() notifications?: ServerNotifications @Input() notifications?: ServerNotifications

View File

@@ -1,69 +1,99 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostListener, computed,
inject, inject,
Input, input,
} from '@angular/core' } from '@angular/core'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { TuiTitle } from '@taiga-ui/core' import { TuiButton } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit' import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { ActionService } from 'src/app/services/action.service' import { ActionService } from 'src/app/services/action.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'button[actionRequest]', selector: 'tr[actionRequest]',
template: ` template: `
<span tuiTitle> <td>
<strong tuiFade><ng-content /></strong> <tui-avatar size="xs"><img [src]="pkg().icon" alt="" /></tui-avatar>
<span tuiSubtitle> <span>{{ title() }}</span>
{{ actionRequest.reason || 'No reason provided' }} </td>
</span> <td>
</span> @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: ` styles: `
:host { td:first-child {
width: 100%; white-space: nowrap;
margin: 0 -1rem; max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
} }
strong { td:last-child {
white-space: nowrap; 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, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle, TuiFade], imports: [TuiButton, TuiAvatar],
hostDirectives: [TuiCell],
}) })
export class ServiceActionRequestComponent { export class ServiceActionRequestComponent {
private readonly actionService = inject(ActionService) private readonly actionService = inject(ActionService)
@Input({ required: true }) readonly actionRequest = input.required<T.ActionRequest>()
actionRequest!: T.ActionRequest readonly services = input.required<Record<string, PackageDataEntry>>()
@Input({ required: true }) readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
pkg!: PackageDataEntry readonly title = computed(() => getManifest(this.pkg()).title)
@HostListener('click')
async handleAction() {
const { title } = getManifest(this.pkg)
const { actionId, packageId } = this.actionRequest
async handle() {
this.actionService.present({ this.actionService.present({
pkgInfo: { pkgInfo: {
id: packageId, id: this.actionRequest().packageId,
title, title: this.title(),
mainStatus: this.pkg.status.main, mainStatus: this.pkg().status.main,
icon: this.pkg.icon, icon: this.pkg().icon,
}, },
actionInfo: { actionInfo: {
id: actionId, id: this.actionRequest().actionId,
metadata: this.pkg.actions[actionId], metadata: this.pkg().actions[this.actionRequest().actionId],
}, },
requestInfo: this.actionRequest, requestInfo: this.actionRequest(),
}) })
} }
} }

View File

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

View File

@@ -56,8 +56,22 @@ import { getManifest } from 'src/app/utils/get-package-data'
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 1rem; gap: 1rem;
justify-content: center; justify-content: center;
inline-size: 20rem;
max-inline-size: 100%;
margin-block-start: 1rem; 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, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -5,7 +5,7 @@ import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit' import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServiceActionRequestsComponent } from './action-requests.component' import { ServicePlaceholderComponent } from './placeholder.component'
@Component({ @Component({
selector: 'service-dependencies', selector: 'service-dependencies',
@@ -24,33 +24,15 @@ import { ServiceActionRequestsComponent } from './action-requests.component'
</span> </span>
<tui-icon icon="@tui.arrow-right" /> <tui-icon icon="@tui.arrow-right" />
</a> </a>
@if (services[d.key]; as service) {
<service-action-requests [pkg]="service" />
}
} @empty { } @empty {
<blockquote>No dependencies</blockquote> <service-placeholder icon="@tui.boxes">
No dependencies
</service-placeholder>
} }
`, `,
styles: ` styles: `
a { :host {
margin: 0 -1rem; grid-column: span 3;
&::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: { class: 'g-card' }, host: { class: 'g-card' },
@@ -58,12 +40,12 @@ import { ServiceActionRequestsComponent } from './action-requests.component'
standalone: true, standalone: true,
imports: [ imports: [
KeyValuePipe, KeyValuePipe,
RouterLink,
TuiCell, TuiCell,
TuiAvatar, TuiAvatar,
TuiTitle, TuiTitle,
ServiceActionRequestsComponent,
RouterLink,
TuiIcon, TuiIcon,
ServicePlaceholderComponent,
], ],
}) })
export class ServiceDependenciesComponent { export class ServiceDependenciesComponent {

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,23 +27,21 @@ import { ServiceInterfaceComponent } from './interface.component'
<th tuiTh></th> <th tuiTh></th>
</tr> </tr>
</thead> </thead>
@for (info of interfaces(); track $index) { <tbody>
<tr @for (info of interfaces(); track $index) {
serviceInterface <tr
[info]="info" serviceInterface
[pkg]="pkg()" [info]="info"
[disabled]="disabled()" [pkg]="pkg()"
></tr> [disabled]="disabled()"
} ></tr>
}
</tbody>
</table> </table>
`, `,
styles: ` styles: `
:host { :host {
grid-column: span 2; grid-column: span 4;
}
table {
margin: 0 -0.5rem;
} }
`, `,
host: { class: 'g-card' }, 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, Input } from '@angular/core'
import { import { TuiLoader } from '@taiga-ui/core'
ChangeDetectionStrategy,
Component,
HostBinding,
Input,
} from '@angular/core'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { InstallingInfo } from 'src/app/services/patch-db/data-model' import { InstallingInfo } from 'src/app/services/patch-db/data-model'
import { import {
PrimaryRendering, PrimaryRendering,
PrimaryStatus, PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service' } from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe' import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@@ -18,20 +11,25 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
selector: 'service-status', selector: 'service-status',
template: ` template: `
<header>Status</header> <header>Status</header>
<div [class]="class"> <div>
@if (installingInfo) { @if (installingInfo) {
<strong> <h3>
<tui-loader size="s" [inheritColor]="true" /> <tui-loader size="s" [inheritColor]="true" />
Installing Installing
<span class="loading-dots"></span> <span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }} {{ installingInfo.progress.overall | installingProgressString }}
</strong> </h3>
} @else { } @else {
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" /> <h3 [class]="class">
{{ connected ? rendering.display : 'Unknown' }} {{ text }}
@if (rendering.showDots) { @if (text === 'Action Required') {
<span class="loading-dots"></span> <small>See below</small>
} }
@if (rendering.showDots) {
<span class="loading-dots"></span>
}
</h3>
} }
<ng-content /> <ng-content />
</div> </div>
@@ -39,17 +37,29 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
styles: [ styles: [
` `
:host { :host {
display: grid; grid-column: span 2;
grid-template-rows: min-content 1fr;
align-items: center;
font: var(--tui-font-heading-6);
text-align: center;
} }
status { h3 {
display: grid; font: var(--tui-font-heading-4);
grid-template-rows: min-content 1fr 1fr; font-weight: normal;
margin: 0;
}
div {
display: flex;
flex-direction: column;
align-items: center; 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 { tui-loader {
@@ -57,12 +67,24 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
vertical-align: bottom; vertical-align: bottom;
margin: 0 0.25rem -0.125rem 0; 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' }, host: { class: 'g-card' },
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InstallingProgressDisplayPipe, TuiIcon, TuiLoader], imports: [InstallingProgressDisplayPipe, TuiLoader],
}) })
export class ServiceStatusComponent { export class ServiceStatusComponent {
@Input({ required: true }) @Input({ required: true })
@@ -74,6 +96,10 @@ export class ServiceStatusComponent {
@Input() @Input()
connected = false connected = false
get text() {
return this.connected ? this.rendering.display : 'Unknown'
}
get class(): string | null { get class(): string | null {
if (!this.connected) return null if (!this.connected) return null
@@ -94,21 +120,4 @@ export class ServiceStatusComponent {
get rendering() { get rendering() {
return PrimaryRendering[this.status] 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 { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service' 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 { getManifest } from 'src/app/utils/get-package-data'
import { ServiceComponent } from './service.component' import { ServiceComponent } from './service.component'
import { ServicesService } from './services.service' import { ServicesService } from './services.service'
@@ -12,6 +13,7 @@ import { ServicesService } from './services.service'
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<ng-container *title>Services</ng-container>
<table tuiTable class="g-table" [(sorter)]="sorter"> <table tuiTable class="g-table" [(sorter)]="sorter">
<thead> <thead>
<tr> <tr>
@@ -55,7 +57,7 @@ import { ServicesService } from './services.service'
} }
`, `,
host: { class: 'g-page' }, host: { class: 'g-page' },
imports: [ServiceComponent, ToManifestPipe, TuiTable], imports: [ServiceComponent, ToManifestPipe, TuiTable, TitleDirective],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export default class DashboardComponent { export default class DashboardComponent {

View File

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

View File

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

View File

@@ -1,53 +1,65 @@
import { CommonModule } from '@angular/common' import {
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' ChangeDetectionStrategy,
import { ActivatedRoute } from '@angular/router' 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 { getPkgId } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component' 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 { 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({ @Component({
template: ` template: `
<app-interface <ng-container *title>
*ngIf="interfacesWithAddresses$ | async as serviceInterface" <a routerLink="../.." tuiIconButton iconStart="@tui.arrow-left">Back</a>
[packageId]="context.packageId" {{ interface()?.name }}
[serviceInterface]="serviceInterface" </ng-container>
/> @if (interface(); as serviceInterface) {
<app-interface
[packageId]="pkgId"
[serviceInterface]="serviceInterface"
/>
}
`, `,
host: { class: 'g-subpage' }, host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, InterfaceComponent], imports: [InterfaceComponent, RouterLink, TuiButton, TitleDirective],
}) })
export default class ServiceInterfaceRoute { export default class ServiceInterfaceRoute {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly config = inject(ConfigService) private readonly config = inject(ConfigService)
readonly context = { readonly pkgId = getPkgId()
packageId: getPkgId(), readonly interfaceId = input('')
interfaceId:
inject(ActivatedRoute).snapshot.paramMap.get('interfaceId') || '',
}
readonly interfacesWithAddresses$ = combineLatest([ readonly pkg = toSignal(
this.patch.watch$( inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
'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 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' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' 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 { TuiAvatar, TuiFade } from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout' import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs' import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model' 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' import { getManifest } from 'src/app/utils/get-package-data'
const ICONS = { const ICONS = {
@@ -25,6 +26,13 @@ const ICONS = {
@Component({ @Component({
template: ` template: `
@if (service()) { @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> <aside>
<header tuiCell> <header tuiCell>
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar> <tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
@@ -58,6 +66,16 @@ const ICONS = {
padding: 0; padding: 0;
} }
.title {
display: flex;
align-items: center;
margin-inline-start: -1rem;
&:not(:only-child) {
display: none;
}
}
aside { aside {
position: sticky; position: sticky;
top: 1px; top: 1px;
@@ -108,10 +126,13 @@ const ICONS = {
flex: 1; flex: 1;
justify-content: center; justify-content: center;
border-radius: 0; border-radius: 0;
padding: 0.125rem;
background: var(--tui-background-neutral-1); background: var(--tui-background-neutral-1);
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
&.active { &.active {
background: none; background: none;
box-shadow: none;
} }
[tuiTitle] { [tuiTitle] {
@@ -131,6 +152,8 @@ const ICONS = {
TuiAppearance, TuiAppearance,
TuiIcon, TuiIcon,
TuiFade, TuiFade,
TitleDirective,
TuiButton,
], ],
providers: [tuiCellOptionsProvider({ height: 'spacious' })], providers: [tuiCellOptionsProvider({ height: 'spacious' })],
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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