chore: refactor settings (#2846)

* small type changes and clear todos

* handle notifications and metrics

* wip

* fixes

* migration

* dedup all urls

* better handling of clearnet ips

* add rfkill dependency

* chore: refactor settings

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Alex Inkin
2025-03-10 23:09:08 +04:00
committed by GitHub
parent fa3329abf2
commit be0371fb11
60 changed files with 746 additions and 1207 deletions

View File

@@ -103,8 +103,6 @@ tui-hint[data-appearance='onDark'] {
tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
border: 0; border: 0;
backdrop-filter: blur(0.25rem); backdrop-filter: blur(0.25rem);
border-radius: 0.325rem;
// TODO: Replace --tui-background-elevation-2 when Taiga UI is updated
background-color: color-mix( background-color: color-mix(
in hsl, in hsl,
var(--tui-background-elevation-3) 75%, var(--tui-background-elevation-3) 75%,
@@ -129,30 +127,6 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
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),
var(--tui-shadow-medium); var(--tui-shadow-medium);
tui-opt-group {
&::before {
background: var(--tui-background-neutral-1);
height: 1px;
}
&::after {
display: none;
}
}
[tuiOption] {
border-radius: 0.1875rem !important;
transition-property: background, box-shadow;
&:focus,
&._with-dropdown {
box-shadow:
inset 0 -1px rgba(0, 0, 0, 0.3),
inset 0 1px rgba(255, 255, 255, 0.1),
inset 0 -3rem 4rem -2rem rgba(0, 0, 0, 0.3);
}
}
} }
[tuiSidebar] > div.t-wrapper { [tuiSidebar] > div.t-wrapper {

View File

@@ -1,62 +0,0 @@
<ion-item *ngIf="iFace">
<ion-icon
slot="start"
size="large"
[name]="
iFace.type === 'ui'
? 'desktop-outline'
: iFace.type === 'api'
? 'terminal-outline'
: 'people-outline'
"
></ion-icon>
<ion-label>
<h1>{{ iFace.name }}</h1>
<h2>{{ iFace.description }}</h2>
<ion-button style="margin-right: 8px" (click)="presentDomainForm()">
Add Domain
</ion-button>
<ion-button
[color]="iFace.public ? 'danger' : 'success'"
(click)="togglePublic()"
>
Make {{ iFace.public ? 'Private' : 'Public' }}
</ion-button>
</ion-label>
</ion-item>
<div *ngIf="iFace" style="padding-left: 64px">
<ion-item *ngFor="let address of iFace.addresses">
<ion-label>
<h2>{{ address.name }}</h2>
<p>{{ address.url }}</p>
<ion-button
*ngIf="address.isDomain"
color="danger"
(click)="removeStandard(address.url)"
>
Remove
</ion-button>
<ion-button
*ngIf="address.isOnion"
color="danger"
(click)="removeOnion(address.url)"
>
Remove
</ion-button>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="address.isDomain" (click)="showAcme(address.acme)">
<ion-icon name="finger-print"></ion-icon>
</ion-button>
<ion-button *ngIf="iFace.type === 'ui'" (click)="launch(address.url)">
<ion-icon name="open-outline"></ion-icon>
</ion-button>
<ion-button (click)="showQR(address.url)">
<ion-icon name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button (click)="copy(address.url)">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</div>

View File

@@ -1,3 +0,0 @@
p {
font-family: 'Courier New';
}

View File

@@ -1,393 +0,0 @@
import { Component, Inject, Input } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import {
AlertController,
ModalController,
ToastController,
} from '@ionic/angular'
import {
copyToClipboard,
ErrorService,
LoadingService,
} from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { firstValueFrom } from 'rxjs'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/components/form.component'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { toAcmeName } from 'src/app/util/acme'
import { ConfigService } from 'src/app/services/config.service'
export type MappedInterface = T.ServiceInterface & {
addresses: MappedAddress[]
public: boolean
}
export type MappedAddress = {
name: string
url: string
isDomain: boolean
isOnion: boolean
acme: string | null
}
@Component({
selector: 'interface-info',
templateUrl: './interface-info.component.html',
styleUrls: ['./interface-info.component.scss'],
})
export class InterfaceInfoComponent {
@Input() pkgId?: string
@Input() iFace!: MappedInterface
constructor(
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly formDialog: FormDialogService,
private readonly alertCtrl: AlertController,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
launch(url: string): void {
this.windowRef.open(url, '_blank', 'noreferrer')
}
async togglePublic() {
const loader = this.loader
.open(`Making ${this.iFace.public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.iFace.addressInfo.internalPort,
public: !this.iFace.public,
}
try {
if (this.pkgId) {
await this.api.pkgBindingSetPubic({
...params,
host: this.iFace.addressInfo.hostId,
package: this.pkgId,
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async presentDomainForm() {
const acme = await firstValueFrom(this.patch.watch$('serverInfo', 'acme'))
const spec = getDomainSpec(Object.keys(acme))
this.formDialog.open(FormComponent, {
label: 'Add Domain',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: 'Save',
handler: async (val: typeof spec._TYPE) => {
if (val.type.selection === 'standard') {
return this.saveStandard(
val.type.value.domain,
val.type.value.acme,
)
} else {
return this.saveTor(val.type.value.key)
}
},
},
],
},
})
}
async removeStandard(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
domain: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async removeOnion(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
onion: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveOnion({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async showAcme(url: string | null): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'ACME Provider',
message: toAcmeName(url),
})
await alert.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
async copy(address: string): Promise<void> {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
private async saveStandard(domain: string, acme: string) {
const loader = this.loader.open('Saving').subscribe()
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.pkgId) {
await this.api.pkgAddDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async saveTor(key: string | null) {
const loader = this.loader.open('Creating onion address').subscribe()
try {
let onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.pkgId) {
await this.api.pkgAddOnion({
onion,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
function getDomainSpec(acme: string[]) {
return ISB.InputSpec.of({
type: ISB.Value.union(
{ name: 'Type', default: 'standard' },
ISB.Variants.of({
standard: {
name: 'Standard',
spec: ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: acme.reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
},
onion: {
name: 'Onion',
spec: ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
},
}),
),
})
}
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
config: ConfigService,
): MappedAddress[] {
const addressInfo = serviceInterface.addressInfo
let hostnames = host.hostnameInfo[addressInfo.internalPort]
hostnames = hostnames.filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (config.isLocalhost()) {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnames.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
const mappedAddresses = hostnames.flatMap(h => {
let name = ''
let isDomain = false
let isOnion = false
let acme: string | null = null
if (h.kind === 'onion') {
name = `Tor`
isOnion = true
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
name = 'Domain'
isDomain = true
acme = host.domains[h.hostname.domain]?.acme
} else {
name =
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`
}
}
const addresses = utils.addressHostToUrl(addressInfo, h)
if (addresses.length > 1) {
return addresses.map(url => ({
name: `${name} (${new URL(url).protocol
.replace(':', '')
.toUpperCase()})`,
url,
isDomain,
isOnion,
acme,
}))
} else {
return addresses.map(url => ({
name,
url,
isDomain,
isOnion,
acme,
}))
}
})
return mappedAddresses.filter(
(value, index, self) => index === self.findIndex(t => t.url === value.url),
)
}

View File

@@ -1,11 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { InterfaceInfoComponent } from './interface-info.component'
@NgModule({
declarations: [InterfaceInfoComponent],
imports: [CommonModule, IonicModule],
exports: [InterfaceInfoComponent],
})
export class InterfaceInfoModule {}

View File

@@ -1,63 +0,0 @@
import { TuiDataList, TuiIcon } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
export interface Action {
icon: string
label: string
action: () => void
}
@Component({
selector: 'app-actions',
template: `
<tui-data-list>
<h3 class="title"><ng-content /></h3>
<tui-opt-group
*ngFor="let group of actions | keyvalue: asIsOrder"
[label]="group.key.toUpperCase()"
>
<button
*ngFor="let action of group.value"
tuiOption
class="item"
(click)="action.action()"
>
<tui-icon class="icon" [icon]="action.icon" />
{{ action.label }}
</button>
</tui-opt-group>
</tui-data-list>
`,
styles: [
`
.title {
margin: 0;
padding: 0 0.5rem 0.25rem;
white-space: nowrap;
font: var(--tui-font-text-l);
font-weight: bold;
}
.item {
justify-content: flex-start;
gap: 0.75rem;
}
.icon {
opacity: var(--tui-disabled-opacity);
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiDataList, CommonModule, TuiIcon],
})
export class ActionsComponent {
@Input()
actions: Record<string, readonly Action[]> = {}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,7 +1,16 @@
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 { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiButton, TuiDataList, TuiDropdown, TuiIcon } from '@taiga-ui/core' import {
TuiButton,
TuiDataList,
TuiDialogOptions,
TuiDropdown,
TuiIcon,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
import { filter } from 'rxjs'
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 { STATUS } from 'src/app/services/status.service' import { STATUS } from 'src/app/services/status.service'
@@ -28,34 +37,53 @@ import { ABOUT } from './about.component'
</div> </div>
} }
<tui-data-list [style.width.rem]="13"> <tui-data-list [style.width.rem]="13">
<button tuiOption iconStart="@tui.info" (click)="about()"> <tui-opt-group>
About this server <button tuiOption iconStart="@tui.info" (click)="about()">
</button> About this server
<hr /> </button>
@for (link of links; track $index) { </tui-opt-group>
<tui-opt-group label="">
@for (link of links; track $index) {
<a
tuiOption
target="_blank"
rel="noreferrer"
[iconStart]="link.icon"
[href]="link.href"
>
{{ link.name }}
</a>
}
</tui-opt-group>
<tui-opt-group label="">
<a <a
tuiOption tuiOption
target="_blank" iconStart="@tui.settings"
rel="noreferrer" routerLink="/portal/system"
[iconStart]="link.icon" (click)="open = false"
[href]="link.href"
> >
{{ link.name }} System Settings
</a> </a>
} </tui-opt-group>
<hr /> <tui-opt-group label="">
<a <button
tuiOption tuiOption
iconStart="@tui.wrench" iconStart="@tui.refresh-cw"
routerLink="/portal/settings" (click)="promptPower('Restart')"
(click)="open = false" >
> Restart
System Settings </button>
</a> <button
<hr /> tuiOption
<button tuiOption iconStart="@tui.log-out" (click)="logout()"> iconStart="@tui.power"
Logout (click)="promptPower('Shutdown')"
</button> >
Shutdown
</button>
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
Logout
</button>
</tui-opt-group>
</tui-data-list> </tui-data-list>
</ng-template> </ng-template>
`, `,
@@ -98,6 +126,8 @@ 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(TuiResponsiveDialogService) private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
open = false open = false
@@ -108,8 +138,53 @@ export class HeaderMenuComponent {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe() this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
} }
async promptPower(action: 'Restart' | 'Shutdown') {
this.dialogs
.open(TUI_CONFIRM, getOptions(action))
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.api[
action === 'Restart' ? 'restartServer' : 'shutdownServer'
]({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
logout() { logout() {
this.api.logout({}).catch(e => console.error('Failed to log out', e)) this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified() this.auth.setUnverified()
} }
} }
function getOptions(
operation: 'Restart' | 'Shutdown',
): Partial<TuiDialogOptions<TuiConfirmData>> {
return operation === 'Restart'
? {
label: 'Restart',
size: 's',
data: {
content:
'Are you sure you want to restart your server? It can take several minutes to come back online.',
yes: 'Restart',
no: 'Cancel',
},
}
: {
label: 'Warning',
size: 's',
data: {
content:
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
yes: 'Shutdown',
no: 'Cancel',
},
}
}

View File

@@ -3,7 +3,7 @@ import { TuiIcon } from '@taiga-ui/core'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'service-placeholder', selector: 'app-placeholder',
template: '<tui-icon [icon]="icon()" /><ng-content/>', template: '<tui-icon [icon]="icon()" /><ng-content/>',
styles: ` styles: `
:host { :host {
@@ -26,6 +26,6 @@ import { TuiIcon } from '@taiga-ui/core'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon], imports: [TuiIcon],
}) })
export class ServicePlaceholderComponent { export class PlaceholderComponent {
readonly icon = input.required<string>() readonly icon = input.required<string>()
} }

View File

@@ -15,7 +15,7 @@ import { BadgeService } from 'src/app/services/badge.service'
import { RESOURCES } from 'src/app/utils/resources' import { RESOURCES } from 'src/app/utils/resources'
import { getMenu } from 'src/app/utils/system-utilities' import { getMenu } from 'src/app/utils/system-utilities'
const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace'] const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
@Component({ @Component({
standalone: true, standalone: true,
@@ -43,12 +43,12 @@ const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace']
<a <a
tuiTabBarItem tuiTabBarItem
icon="@tui.settings" icon="@tui.settings"
routerLink="/portal/settings" routerLink="/portal/system"
routerLinkActive routerLinkActive
[badge]="badge()" [badge]="badge()"
(isActiveChange)="update()" (isActiveChange)="update()"
> >
Settings System
</a> </a>
<button <button
tuiTabBarItem tuiTabBarItem
@@ -140,7 +140,7 @@ export class TabsComponent {
readonly resources = RESOURCES readonly resources = RESOURCES
readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink)) readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink))
readonly badge = toSignal(inject(BadgeService).getCount('/portal/settings'), { readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'), {
initialValue: 0, initialValue: 0,
}) })

View File

@@ -40,9 +40,9 @@ const ROUTES: Routes = [
}, },
{ {
title: systemTabResolver, title: systemTabResolver,
path: 'settings', path: 'system',
loadChildren: () => import('./routes/settings/settings.routes'), loadChildren: () => import('./routes/system/system.routes'),
data: toNavigationItem('/portal/settings'), data: toNavigationItem('/portal/system'),
}, },
{ {
title: systemTabResolver, title: systemTabResolver,

View File

@@ -7,6 +7,7 @@ import {
TuiIcon, TuiIcon,
TuiButton, TuiButton,
TuiNotification, TuiNotification,
TuiLink,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit' import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@@ -25,6 +26,7 @@ import { EDIT } from './edit.component'
data is safely backed up. StartOS will issue a notification whenever one data is safely backed up. StartOS will issue a notification whenever one
of your scheduled backups succeeds or fails. of your scheduled backups succeeds or fails.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/backups/backup-jobs" href="https://docs.start9.com/latest/user-manual/backups/backup-jobs"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -144,6 +146,7 @@ import { EDIT } from './edit.component'
ToHumanCronPipe, ToHumanCronPipe,
GetBackupIconPipe, GetBackupIconPipe,
TuiSkeleton, TuiSkeleton,
TuiLink,
], ],
}) })
export class BackupsJobsModal implements OnInit { export class BackupsJobsModal implements OnInit {

View File

@@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, inject, OnInit, signal } from '@angular/core' import { Component, inject, OnInit, signal } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiNotification } from '@taiga-ui/core' import { TuiButton, TuiLink, TuiNotification } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { FormComponent } from 'src/app/routes/portal/components/form.component' import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { import {
@@ -32,6 +32,7 @@ import {
folders on your Local Area Network (LAN), or third party clouds such as folders on your Local Area Network (LAN), or third party clouds such as
Dropbox or Google Drive. Dropbox or Google Drive.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/backups/backup-targets" href="https://docs.start9.com/latest/user-manual/backups/backup-targets"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -75,6 +76,7 @@ import {
TuiButton, TuiButton,
BackupsPhysicalComponent, BackupsPhysicalComponent,
BackupsTargetsComponent, BackupsTargetsComponent,
TuiLink,
], ],
}) })
export class BackupsTargetsModal implements OnInit { export class BackupsTargetsModal implements OnInit {

View File

@@ -5,9 +5,9 @@ import {
input, input,
} from '@angular/core' } from '@angular/core'
import { TuiTable } from '@taiga-ui/addon-table' import { TuiTable } from '@taiga-ui/addon-table'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServiceActionRequestComponent } from './action-request.component' import { ServiceActionRequestComponent } from './action-request.component'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({ @Component({
standalone: true, standalone: true,
@@ -30,9 +30,9 @@ import { ServicePlaceholderComponent } from './placeholder.component'
</tbody> </tbody>
</table> </table>
@if (!requests().length) { @if (!requests().length) {
<service-placeholder icon="@tui.list-checks"> <app-placeholder icon="@tui.list-checks">
All tasks complete All tasks complete
</service-placeholder> </app-placeholder>
} }
`, `,
styles: ` styles: `
@@ -42,11 +42,7 @@ import { ServicePlaceholderComponent } from './placeholder.component'
`, `,
host: { class: 'g-card' }, host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [TuiTable, ServiceActionRequestComponent, PlaceholderComponent],
TuiTable,
ServiceActionRequestComponent,
ServicePlaceholderComponent,
],
}) })
export class ServiceActionRequestsComponent { export class ServiceActionRequestsComponent {
readonly pkg = input.required<PackageDataEntry>() readonly pkg = input.required<PackageDataEntry>()

View File

@@ -4,8 +4,8 @@ import { RouterLink } from '@angular/router'
import { TuiIcon, TuiTitle } from '@taiga-ui/core' 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 { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({ @Component({
selector: 'service-dependencies', selector: 'service-dependencies',
@@ -25,9 +25,7 @@ import { ServicePlaceholderComponent } from './placeholder.component'
<tui-icon icon="@tui.arrow-right" /> <tui-icon icon="@tui.arrow-right" />
</a> </a>
} @empty { } @empty {
<service-placeholder icon="@tui.boxes"> <app-placeholder icon="@tui.boxes">No dependencies</app-placeholder>
No dependencies
</service-placeholder>
} }
`, `,
styles: ` styles: `
@@ -45,7 +43,7 @@ import { ServicePlaceholderComponent } from './placeholder.component'
TuiAvatar, TuiAvatar,
TuiTitle, TuiTitle,
TuiIcon, TuiIcon,
ServicePlaceholderComponent, PlaceholderComponent,
], ],
}) })
export class ServiceDependenciesComponent { export class ServiceDependenciesComponent {

View File

@@ -1,8 +1,8 @@
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 { TuiTable } from '@taiga-ui/addon-table' import { TuiTable } from '@taiga-ui/addon-table'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ServiceHealthCheckComponent } from './health-check.component' import { ServiceHealthCheckComponent } from './health-check.component'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({ @Component({
standalone: true, standalone: true,
@@ -23,9 +23,9 @@ import { ServicePlaceholderComponent } from './placeholder.component'
</tbody> </tbody>
</table> </table>
@if (!checks().length) { @if (!checks().length) {
<service-placeholder icon="@tui.heart-pulse"> <app-placeholder icon="@tui.heart-pulse">
No health checks No health checks
</service-placeholder> </app-placeholder>
} }
`, `,
styles: ` styles: `
@@ -35,7 +35,7 @@ import { ServicePlaceholderComponent } from './placeholder.component'
`, `,
host: { class: 'g-card' }, host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceHealthCheckComponent, ServicePlaceholderComponent, TuiTable], imports: [ServiceHealthCheckComponent, PlaceholderComponent, TuiTable],
}) })
export class ServiceHealthChecksComponent { export class ServiceHealthChecksComponent {
readonly checks = input.required<readonly T.NamedHealthCheckResult[]>() readonly checks = input.required<readonly T.NamedHealthCheckResult[]>()

View File

@@ -8,7 +8,7 @@ import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { TuiAppearance, TuiButton, 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 } 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'
@@ -155,7 +155,6 @@ const ICONS = {
TitleDirective, TitleDirective,
TuiButton, TuiButton,
], ],
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
}) })
export class ServiceOutletComponent { export class ServiceOutletComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)

View File

@@ -1,47 +0,0 @@
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { SettingBtn } from '../settings.types'
@Component({
selector: 'settings-button',
template: `
<button *ngIf="button.action" class="g-action" (click)="button.action()">
<ng-container *ngTemplateOutlet="template" />
</button>
<a
*ngIf="button.routerLink"
class="g-action"
[routerLink]="button.routerLink"
>
<ng-container *ngTemplateOutlet="template" />
</a>
<ng-template #template>
<tui-icon [icon]="button.icon" />
<div tuiTitle [style.flex]="1">
<strong>{{ button.title }}</strong>
<div tuiSubtitle>{{ button.description }}</div>
<ng-content />
</div>
<tui-icon *ngIf="button.routerLink" icon="@tui.chevron-right" />
</ng-template>
`,
styles: `
:host:not(:last-child) {
display: block;
box-shadow: 0 1px var(--tui-background-neutral-1);
}
button {
cursor: pointer;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIcon, TuiTitle, RouterLink],
})
export class SettingsButtonComponent {
@Input({ required: true })
button!: SettingBtn
}

View File

@@ -1,117 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { SettingsService } from '../settings.service'
import { SettingsSyncComponent } from './sync.component'
import { SettingsButtonComponent } from './button.component'
import { SettingsUpdateComponent } from './update.component'
@Component({
selector: 'settings-menu',
template: `
<ng-container *ngIf="server$ | async as server; else loading">
<settings-sync *ngIf="!server.ntpSynced" />
<section *ngFor="let cat of service.settings | keyvalue: asIsOrder">
<h3 class="g-title" (click)="addClick(cat.key)">{{ cat.key }}</h3>
<settings-update
*ngIf="cat.key === 'General'"
[updated]="server.statusInfo.updated"
/>
<ng-container *ngFor="let btn of cat.value">
<settings-button [button]="btn">
<!-- // @TODO 041
<div
*ngIf="btn.title === 'Outbound Proxy'"
tuiSubtitle
[style.color]="
!server.network.outboundProxy
? 'var(--tui-status-warning)'
: 'var(--tui-status-positive)'
"
>
{{ server.network.outboundProxy || 'None' }}
</div> -->
</settings-button>
</ng-container>
</section>
</ng-container>
<ng-template #loading>
<tui-loader
textContent="Connecting to server"
[style.margin-top.rem]="10"
/>
</ng-template>
`,
styles: [
`
:host {
display: flex;
flex-direction: column;
gap: 1rem;
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiLoader,
TuiButton,
SettingsSyncComponent,
SettingsButtonComponent,
SettingsUpdateComponent,
],
})
export class SettingsMenuComponent {
private readonly clientStorageService = inject(ClientStorageService)
private readonly alerts = inject(TuiAlertService)
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
readonly service = inject(SettingsService)
manageClicks = 0
powerClicks = 0
addClick(title: string) {
switch (title) {
case 'Security':
this.addSecurityClick()
break
case 'Power':
this.addPowerClick()
break
default:
return
}
}
asIsOrder() {
return 0
}
private addSecurityClick() {
this.manageClicks++
if (this.manageClicks === 5) {
this.manageClicks = 0
this.alerts
.open(
this.clientStorageService.toggleShowDevTools()
? 'Dev tools unlocked'
: 'Dev tools hidden',
)
.subscribe()
}
}
private addPowerClick() {
this.powerClicks++
if (this.powerClicks === 5) {
this.powerClicks = 0
this.clientStorageService.toggleShowDiskRepair()
}
}
}

View File

@@ -1,73 +0,0 @@
import { SettingsComponent } from './settings.component'
export default [
{
path: '',
component: SettingsComponent,
children: [
{
path: 'acme',
loadComponent: () =>
import('./routes/acme/acme.component').then(
m => m.SettingsACMEComponent,
),
},
{
path: 'email',
loadComponent: () =>
import('./routes/email/email.component').then(
m => m.SettingsEmailComponent,
),
},
// {
// path: 'domains',
// loadComponent: () =>
// import('./routes/domains/domains.component').then(
// m => m.SettingsDomainsComponent,
// ),
// },
// {
// path: 'proxies',
// loadComponent: () =>
// import('./routes/proxies/proxies.component').then(
// m => m.SettingsProxiesComponent,
// ),
// },
// {
// path: 'router',
// loadComponent: () =>
// import('./routes/router/router.component').then(
// m => m.SettingsRouterComponent,
// ),
// },
{
path: 'wifi',
loadComponent: () =>
import('./routes/wifi/wifi.component').then(
m => m.SettingsWifiComponent,
),
},
{
path: 'ui',
loadComponent: () =>
import('./routes/interfaces/ui.component').then(
m => m.StartOsUiComponent,
),
},
{
path: 'ssh',
loadComponent: () =>
import('./routes/ssh/ssh.component').then(
m => m.SettingsSSHComponent,
),
},
{
path: 'sessions',
loadComponent: () =>
import('./routes/sessions/sessions.component').then(
m => m.SettingsSessionsComponent,
),
},
],
},
]

View File

@@ -0,0 +1,56 @@
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiCell } from '@taiga-ui/layout'
import { SettingBtn } from '../system.types'
@Component({
selector: 'system-button',
template: `
@if (button.action) {
<button tuiCell (click)="button.action()">
<ng-container *ngTemplateOutlet="template" />
</button>
}
@if (button.routerLink) {
<a tuiCell [routerLink]="button.routerLink">
<ng-container *ngTemplateOutlet="template" />
</a>
}
<ng-template #template>
<tui-icon [icon]="button.icon" />
<div tuiTitle>
<strong>{{ button.title }}</strong>
<div tuiSubtitle>{{ button.description }}</div>
<ng-content />
</div>
@if (button.routerLink) {
<tui-icon icon="@tui.chevron-right" />
}
</ng-template>
`,
styles: `
:host {
display: flex;
flex-direction: column;
&:not(:last-child) {
box-shadow: 0 1px var(--tui-background-neutral-1);
}
}
button {
cursor: pointer;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiIcon, TuiTitle, RouterLink, TuiCell],
})
export class SystemButtonComponent {
@Input({ required: true })
button!: SettingBtn
}

View File

@@ -0,0 +1,114 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { SystemService } from '../system.service'
import { SystemSyncComponent } from './sync.component'
import { SystemButtonComponent } from './button.component'
import { SystemUpdateComponent } from './update.component'
@Component({
selector: 'system-menu',
template: `
@if (data(); as server) {
@if (!server.ntpSynced) {
<system-sync />
}
@for (cat of service.settings | keyvalue: asIsOrder; track $index) {
<section class="g-card">
<header (click)="addClick(cat.key)">{{ cat.key }}</header>
@if (cat.key === 'General') {
<system-update [updated]="server.statusInfo.updated" />
}
@for (btn of cat.value; track $index) {
<system-button [button]="btn">
<!-- // @TODO 041
<div
*ngIf="btn.title === 'Outbound Proxy'"
tuiSubtitle
[style.color]="
!server.network.outboundProxy
? 'var(--tui-status-warning)'
: 'var(--tui-status-positive)'
"
>
{{ server.network.outboundProxy || 'None' }}
</div> -->
</system-button>
}
</section>
}
} @else {
<tui-loader
textContent="Connecting to server"
[style.margin-top.rem]="10"
/>
}
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiLoader,
SystemSyncComponent,
SystemButtonComponent,
SystemUpdateComponent,
],
})
export class SystemMenuComponent {
private readonly clientStorageService = inject(ClientStorageService)
private readonly alerts = inject(TuiAlertService)
readonly service = inject(SystemService)
readonly data = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo'),
)
manageClicks = 0
powerClicks = 0
addClick(title: string) {
switch (title) {
case 'Security':
this.addSecurityClick()
break
case 'Power':
this.addPowerClick()
break
default:
return
}
}
asIsOrder() {
return 0
}
private addSecurityClick() {
this.manageClicks++
if (this.manageClicks === 5) {
this.manageClicks = 0
this.alerts
.open(
this.clientStorageService.toggleShowDevTools()
? 'Dev tools unlocked'
: 'Dev tools hidden',
)
.subscribe()
}
}
private addPowerClick() {
this.powerClicks++
if (this.powerClicks === 5) {
this.powerClicks = 0
this.clientStorageService.toggleShowDiskRepair()
}
}
}

View File

@@ -3,7 +3,7 @@ import { TuiTitle, TuiButton, TuiNotification } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({ @Component({
selector: 'settings-sync', selector: 'system-sync',
template: ` template: `
<tui-notification appearance="warning"> <tui-notification appearance="warning">
<div tuiCell [style.padding]="0"> <div tuiCell [style.padding]="0">
@@ -31,4 +31,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
standalone: true, standalone: true,
imports: [TuiButton, TuiCell, TuiNotification, TuiTitle], imports: [TuiButton, TuiCell, TuiNotification, TuiTitle],
}) })
export class SettingsSyncComponent {} export class SystemSyncComponent {}

View File

@@ -7,48 +7,46 @@ import {
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { EOSService } from 'src/app/services/eos.service' import { EOSService } from 'src/app/services/eos.service'
import { UPDATE } from '../modals/update.component' import { UPDATE } from '../modals/update.component'
@Component({ @Component({
selector: 'settings-update', selector: 'system-update',
template: ` template: `
<button <button
class="g-action" tuiCell
[disabled]="service.updatingOrBackingUp$ | async" [disabled]="service.updatingOrBackingUp$ | async"
(click)="onClick()" (click)="onClick()"
> >
<tui-icon icon="@tui.cloud-download"></tui-icon> <tui-icon icon="@tui.cloud-download" />
<div tuiTitle> <div tuiTitle>
<strong>Software Update</strong> <strong>Software Update</strong>
<div tuiSubtitle>Get the latest version of StartOS</div> <div tuiSubtitle>Get the latest version of StartOS</div>
<div @if (updated) {
*ngIf="updated; else notUpdated" <div tuiSubtitle class="g-warning">
tuiSubtitle Update Complete. Restart to apply changes
[style.color]="'var(--tui-status-warning)'" </div>
> } @else {
Update Complete. Restart to apply changes @if (service.showUpdate$ | async) {
</div> <div tuiSubtitle class="g-positive">
<ng-template #notUpdated>
<ng-container *ngIf="service.showUpdate$ | async; else check">
<div tuiSubtitle [style.color]="'var(--tui-status-positive)'">
<tui-icon class="small" icon="@tui.zap" /> <tui-icon class="small" icon="@tui.zap" />
Update Available Update Available
</div> </div>
</ng-container> } @else {
<ng-template #check> <div tuiSubtitle class="g-info">
<div tuiSubtitle [style.color]="'var(--tui-status-info)'">
<tui-icon class="small" icon="@tui.rotate-cw" /> <tui-icon class="small" icon="@tui.rotate-cw" />
Check for updates Check for updates
</div> </div>
</ng-template> }
</ng-template> }
</div> </div>
</button> </button>
`, `,
styles: ` styles: `
:host { :host {
display: block; display: flex;
flex-direction: column;
box-shadow: 0 1px var(--tui-background-neutral-1); box-shadow: 0 1px var(--tui-background-neutral-1);
} }
@@ -62,9 +60,9 @@ import { UPDATE } from '../modals/update.component'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, TuiIcon, TuiTitle], imports: [CommonModule, TuiIcon, TuiTitle, TuiCell],
}) })
export class SettingsUpdateComponent { export class SystemUpdateComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)

View File

@@ -46,7 +46,7 @@ import { EOSService } from 'src/app/services/eos.service'
TuiScrollbar, TuiScrollbar,
], ],
}) })
export class SettingsUpdateModal { export class SystemUpdateModal {
readonly versions = Object.entries(this.eosService.osUpdate?.releaseNotes!) readonly versions = Object.entries(this.eosService.osUpdate?.releaseNotes!)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.reverse() .reverse()
@@ -77,4 +77,4 @@ export class SettingsUpdateModal {
} }
} }
export const UPDATE = new PolymorpheusComponent(SettingsUpdateModal) export const UPDATE = new PolymorpheusComponent(SystemUpdateModal)

View File

@@ -1,47 +1,93 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ISB, utils } from '@start9labs/start-sdk' import { ISB, utils } from '@start9labs/start-sdk'
import { knownACME, toAcmeName } from 'src/app/utils/acme' import { TuiButton, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs' import { map } from 'rxjs'
import { CommonModule } from '@angular/common'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/routes/portal/components/form.component' import { FormComponent } from 'src/app/routes/portal/components/form.component'
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 { knownACME, toAcmeName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { AcmeInfoComponent } from './info.component'
@Component({ @Component({
selector: 'acme', template: `
template: ``, <acme-info />
styles: [], <section class="g-card">
<header>
Saved Providers
@if (acme(); as value) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="addAcme(value)"
>
Add Provider
</button>
}
</header>
@if (acme(); as value) {
@for (provider of value; track $index) {
<div tuiCell>
<span tuiTitle>
<strong>{{ toAcmeName(provider.url) }}</strong>
<span tuiSubtitle>Contact: {{ provider.contactString }}</span>
</span>
<button
tuiIconButton
iconStart="@tui.pencil"
appearance="icon"
(click)="editAcme(provider.url, provider.contact)"
>
Edit
</button>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="icon"
(click)="removeAcme(provider.url)"
>
Edit
</button>
</div>
}
} @else {
<tui-loader [style.height.rem]="5" />
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule], imports: [TuiButton, TuiLoader, TuiCell, TuiTitle, AcmeInfoComponent],
}) })
export class SettingsACMEComponent { export default class SystemAcmeComponent {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme' acme = toSignal(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
acme$ = this.patch.watch$('serverInfo', 'network', 'acme').pipe( map(acme =>
map(acme => { Object.keys(acme).map(url => {
const providerUrls = Object.keys(acme) const contact = acme[url].contact.map(mailto =>
return providerUrls.map(url => { mailto.replace('mailto:', ''),
const contact = acme[url].contact.map(mailto => )
mailto.replace('mailto:', ''), return {
) url,
return { contact,
url, contactString: contact.join(', '),
contact, }
contactString: contact.join(', '), }),
} ),
}) ),
}),
) )
toAcmeName = toAcmeName toAcmeName = toAcmeName

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({
selector: 'acme-info',
template: `
<tui-notification>
Register with one or more ACME providers such as Let's Encrypt in order to
generate SSL (https) certificates on-demand for clearnet hosting.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/acme"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotification, TuiLink],
})
export class AcmeInfoComponent {}

View File

@@ -60,7 +60,7 @@ import { DomainsTableComponent } from './table.component'
DomainsInfoComponent, DomainsInfoComponent,
], ],
}) })
export class SettingsDomainsComponent { export default class SystemDomainsComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
@@ -103,7 +103,7 @@ export class SettingsDomainsComponent {
buttons: [ buttons: [
{ {
text: 'Manage proxies', text: 'Manage proxies',
link: '/portal/settings/proxies', link: '/portal/system/proxies',
}, },
{ {
text: 'Save', text: 'Save',
@@ -128,7 +128,7 @@ export class SettingsDomainsComponent {
buttons: [ buttons: [
{ {
text: 'Manage proxies', text: 'Manage proxies',
link: '/portal/settings/proxies', link: '/portal/system/proxies',
}, },
{ {
text: 'Save', text: 'Save',

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core' import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'domains-info', selector: 'domains-info',
@@ -7,6 +7,7 @@ import { TuiNotification } from '@taiga-ui/core'
<tui-notification> <tui-notification>
Adding domains permits accessing your server and services over clearnet. Adding domains permits accessing your server and services over clearnet.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/domains" href="https://docs.start9.com/latest/user-manual/domains"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -17,6 +18,6 @@ import { TuiNotification } from '@taiga-ui/core'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class DomainsInfoComponent {} export class DomainsInfoComponent {}

View File

@@ -1,17 +1,13 @@
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 { import { FormsModule, ReactiveFormsModule } from '@angular/forms'
FormsModule,
ReactiveFormsModule,
UntypedFormGroup,
} from '@angular/forms'
import { RouterLink } from '@angular/router' 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'
import { TuiInputModule } from '@taiga-ui/legacy' import { TuiInputModule } from '@taiga-ui/legacy'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { switchMap } from 'rxjs' import { switchMap, tap } from 'rxjs'
import { FormModule } from 'src/app/routes/portal/components/form/form.module' 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'
@@ -28,33 +24,33 @@ import { EmailInfoComponent } from './info.component'
</ng-container> </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 class="g-card" [formGroup]="form">
<h3 class="g-title">SMTP Credentials</h3> <header>SMTP Credentials</header>
<form-group <form-group
*ngIf="spec | async as resolved" *ngIf="spec | async as resolved"
[spec]="resolved" [spec]="resolved"
></form-group> ></form-group>
<button <footer>
*ngIf="isSaved" @if (isSaved) {
tuiButton <button
appearance="secondary-destructive" tuiButton
[style.margin-top.rem]="1" appearance="secondary-destructive"
[style.margin-right.rem]="1" (click)="save(null)"
(click)="save(null)" >
> Delete
Delete </button>
</button> }
<button <button
tuiButton tuiButton
[style.margin-top.rem]="1" [disabled]="form.invalid"
[disabled]="form.invalid" (click)="save(form.value)"
(click)="save(form.value)" >
> Save
Save </button>
</button> </footer>
</form> </form>
<form [style.text-align]="'right'"> <form class="g-card">
<h3 class="g-title">Test Email</h3> <header>Send Test Email</header>
<tui-input <tui-input
[(ngModel)]="testAddress" [(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
@@ -62,19 +58,19 @@ import { EmailInfoComponent } from './info.component'
Firstname Lastname &lt;email&#64;example.com&gt; Firstname Lastname &lt;email&#64;example.com&gt;
<input tuiTextfieldLegacy inputmode="email" /> <input tuiTextfieldLegacy inputmode="email" />
</tui-input> </tui-input>
<button <footer>
tuiButton <button
appearance="secondary" tuiButton
[style.margin-top.rem]="1" appearance="secondary"
[disabled]="!testAddress || form.invalid" [disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form)" (click)="sendTestEmail(form.value)"
> >
Send Test Email Send
</button> </button>
</footer>
</form> </form>
</ng-container> </ng-container>
`, `,
styles: ['form { margin: auto; max-width: 30rem; }'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [
@@ -89,7 +85,7 @@ import { EmailInfoComponent } from './info.component'
TitleDirective, TitleDirective,
], ],
}) })
export class SettingsEmailComponent { export default class SystemEmailComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
@@ -103,13 +99,12 @@ export class SettingsEmailComponent {
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec( readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
inputSpec.constants.customSmtp, inputSpec.constants.customSmtp,
) )
readonly form$ = this.patch readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
.watch$('serverInfo', 'smtp') tap(value => (this.isSaved = !!value)),
.pipe( switchMap(async value =>
switchMap(async value => this.formService.createForm(await this.spec, value),
this.formService.createForm(await this.spec, value), ),
), )
)
async save( async save(
value: typeof inputSpec.constants.customSmtp._TYPE | null, value: typeof inputSpec.constants.customSmtp._TYPE | null,
@@ -131,13 +126,13 @@ export class SettingsEmailComponent {
} }
} }
async sendTestEmail(form: UntypedFormGroup) { async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
const loader = this.loader.open('Sending...').subscribe() const loader = this.loader.open('Sending email...').subscribe()
try { try {
await this.api.testSmtp({ await this.api.testSmtp({
to: this.testAddress, to: this.testAddress,
...form.value, ...value,
}) })
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core' import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'email-info', selector: 'email-info',
@@ -8,6 +8,7 @@ import { TuiNotification } from '@taiga-ui/core'
Adding SMTP credentials to StartOS enables StartOS and some services to Adding SMTP credentials to StartOS enables StartOS and some services to
send you emails. send you emails.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/smtp" href="https://docs.start9.com/latest/user-manual/smtp"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -18,6 +19,6 @@ import { TuiNotification } from '@taiga-ui/core'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class EmailInfoComponent {} export class EmailInfoComponent {}

View File

@@ -53,7 +53,7 @@ const iface: T.ServiceInterface = {
TitleDirective, TitleDirective,
], ],
}) })
export class StartOsUiComponent { export default class StartOsUiComponent {
private readonly config = inject(ConfigService) private readonly config = inject(ConfigService)
readonly ui$: Observable<MappedServiceInterface> = inject<PatchDB<DataModel>>( readonly ui$: Observable<MappedServiceInterface> = inject<PatchDB<DataModel>>(

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core' import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'proxies-info', selector: 'proxies-info',
@@ -25,6 +25,7 @@ import { TuiNotification } from '@taiga-ui/core'
</li> </li>
</ol> </ol>
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/vpns/" href="https://docs.start9.com/latest/user-manual/vpns/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -35,6 +36,6 @@ import { TuiNotification } from '@taiga-ui/core'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class ProxiesInfoComponent {} export class ProxiesInfoComponent {}

View File

@@ -35,7 +35,7 @@ import { wireguardSpec, WireguardSpec } from './constants'
ProxiesTableComponent, ProxiesTableComponent,
], ],
}) })
export class SettingsProxiesComponent { export default class SystemProxiesComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)

View File

@@ -22,7 +22,7 @@ import {
import { import {
DELETE_OPTIONS, DELETE_OPTIONS,
ProxyUpdate, ProxyUpdate,
} from 'src/app/routes/portal/routes/settings/routes/proxies/constants' } from 'src/app/routes/portal/routes/system/routes/proxies/constants'
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 { Proxy } from 'src/app/services/patch-db/data-model' import { Proxy } from 'src/app/services/patch-db/data-model'

View File

@@ -1,12 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { CommonModule } from '@angular/common' import { TuiLink, TuiNotification } from '@taiga-ui/core'
import { TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'router-info', selector: 'router-info',
template: ` template: `
<tui-notification [appearance]="enabled ? 'positive' : 'warning'"> <tui-notification [appearance]="enabled ? 'positive' : 'warning'">
<ng-container *ngIf="enabled; else disabled"> @if (enabled) {
<strong>UPnP Enabled!</strong> <strong>UPnP Enabled!</strong>
<p> <p>
The ports below have been The ports below have been
@@ -16,14 +15,14 @@ import { TuiNotification } from '@taiga-ui/core'
If you are running multiple servers, you may want to override specific If you are running multiple servers, you may want to override specific
ports to suite your needs. ports to suite your needs.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/port-forwards/upnp#override" href="https://docs.start9.com/latest/user-manual/port-forwards/upnp#override"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
View instructions View instructions
</a> </a>
</ng-container> } @else {
<ng-template #disabled>
<strong>UPnP Disabled</strong> <strong>UPnP Disabled</strong>
<p> <p>
Below are a list of ports that must be Below are a list of ports that must be
@@ -33,19 +32,20 @@ import { TuiNotification } from '@taiga-ui/core'
Alternatively, you can enable UPnP in your router for automatic Alternatively, you can enable UPnP in your router for automatic
configuration. configuration.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/port-forwards/manual" href="https://docs.start9.com/latest/user-manual/port-forwards/manual"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
View instructions View instructions
</a> </a>
</ng-template> }
</tui-notification> </tui-notification>
`, `,
styles: ['strong { font-size: 1rem }'], styles: ['strong { font-size: 1rem }'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class RouterInfoComponent { export class RouterInfoComponent {
@Input() @Input()

View File

@@ -64,6 +64,6 @@ import { RouterPortComponent } from './table.component'
PrimaryIpPipe, PrimaryIpPipe,
], ],
}) })
export class SettingsRouterComponent { export default class SystemRouterComponent {
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo') readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
} }

View File

@@ -1,4 +1,5 @@
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
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'
@@ -16,29 +17,34 @@ import { SSHTableComponent } from './table.component'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a> <a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Active Sessions Active Sessions
</ng-container> </ng-container>
<h3 class="g-title">Current session</h3> <section class="g-card">
<table <header>Current session</header>
class="g-table" <table
[single]="true" tuiTable
[sessions]="current$ | async" class="g-table"
></table> [single]="true"
[sessions]="current$ | async"
></table>
</section>
<ng-container *tuiLet="other$ | async as others"> <section *tuiLet="other$ | async as others" class="g-card">
<h3 class="g-title"> <header>
Other sessions Other sessions
<button @if (table.selected$ | async; as selected) {
*ngIf="table.selected$ | async as selected" <button
tuiButton tuiButton
size="xs" size="xs"
appearance="error" appearance="negative"
[disabled]="!selected.length" [style.margin-inline-start]="'auto'"
(click)="terminate(selected, others || [])" [disabled]="!selected.length"
> (click)="terminate(selected, others || [])"
Terminate selected >
</button> Terminate selected
</h3> </button>
<table #table class="g-table" [sessions]="others"></table> }
</ng-container> </header>
<table #table tuiTable class="g-table" [sessions]="others"></table>
</section>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
@@ -49,9 +55,10 @@ import { SSHTableComponent } from './table.component'
TuiLet, TuiLet,
RouterLink, RouterLink,
TitleDirective, TitleDirective,
TuiTable,
], ],
}) })
export class SettingsSessionsComponent { export default class SystemSessionsComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)

View File

@@ -1,4 +1,3 @@
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -7,7 +6,9 @@ import {
OnChanges, OnChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { TuiIcon, TuiLink, TuiButton } from '@taiga-ui/core' import { TuiTable } from '@taiga-ui/addon-table'
import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { BehaviorSubject } from 'rxjs' import { BehaviorSubject } from 'rxjs'
import { Session } from 'src/app/services/api/api.types' import { Session } from 'src/app/services/api/api.types'
import { PlatformInfoPipe } from './platform-info.pipe' import { PlatformInfoPipe } from './platform-info.pipe'
@@ -17,7 +18,11 @@ import { PlatformInfoPipe } from './platform-info.pipe'
template: ` template: `
<thead> <thead>
<tr> <tr>
<th [style.width.%]="50" [style.padding-left.rem]="single ? null : 2"> <th
tuiTh
[style.width.%]="50"
[style.padding-left.rem]="single ? null : 2"
>
@if (!single) { @if (!single) {
<input <input
tuiCheckbox tuiCheckbox
@@ -30,14 +35,14 @@ import { PlatformInfoPipe } from './platform-info.pipe'
} }
User Agent User Agent
</th> </th>
<th [style.width.%]="25">Platform</th> <th tuiTh [style.width.%]="25">Platform</th>
<th [style.width.%]="25">Last Active</th> <th tuiTh [style.width.%]="25">Last Active</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (session of sessions; track $index) { @for (session of sessions; track $index) {
<tr> <tr>
<td [style.padding-left.rem]="single ? null : 2"> <td [style.padding-left.rem]="single ? null : 2.25">
@if (!single) { @if (!single) {
<input <input
tuiCheckbox tuiCheckbox
@@ -74,10 +79,14 @@ import { PlatformInfoPipe } from './platform-info.pipe'
` `
@import '@taiga-ui/core/styles/taiga-ui-local'; @import '@taiga-ui/core/styles/taiga-ui-local';
td {
position: relative;
}
input { input {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 0.25rem; left: 0.5rem;
transform: translateY(-50%); transform: translateY(-50%);
} }
@@ -118,12 +127,11 @@ import { PlatformInfoPipe } from './platform-info.pipe'
CommonModule, CommonModule,
FormsModule, FormsModule,
PlatformInfoPipe, PlatformInfoPipe,
TuiButton,
TuiLink,
TuiIcon, TuiIcon,
TuiCheckbox, TuiCheckbox,
TuiFade, TuiFade,
TuiSkeleton, TuiSkeleton,
TuiTable,
], ],
}) })
export class SSHTableComponent<T extends Session> implements OnChanges { export class SSHTableComponent<T extends Session> implements OnChanges {

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core' import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'ssh-info', selector: 'ssh-info',
@@ -8,6 +8,7 @@ import { TuiNotification } from '@taiga-ui/core'
Adding domains to StartOS enables you to access your server and service Adding domains to StartOS enables you to access your server and service
interfaces over clearnet. interfaces over clearnet.
<a <a
tuiLink
href="https://docs.start9.com/0.3.5.x/user-manual/ssh" href="https://docs.start9.com/0.3.5.x/user-manual/ssh"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -18,6 +19,6 @@ import { TuiNotification } from '@taiga-ui/core'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class SSHInfoComponent {} export class SSHInfoComponent {}

View File

@@ -1,4 +1,5 @@
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
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'
@@ -16,18 +17,21 @@ import { SSHTableComponent } from './table.component'
SSH SSH
</ng-container> </ng-container>
<ssh-info /> <ssh-info />
<h3 class="g-title"> <section class="g-card">
Saved Keys <header>
<button Saved Keys
tuiButton <button
size="xs" tuiButton
iconStart="@tui.plus" size="xs"
(click)="table.add.call(table)" iconStart="@tui.plus"
> [style.margin-inline-start]="'auto'"
Add Key (click)="table.add.call(table)"
</button> >
</h3> Add Key
<table #table class="g-table" [keys]="keys$ | async"></table> </button>
</header>
<table #table tuiTable class="g-table" [keys]="keys$ | async"></table>
</section>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
@@ -38,9 +42,10 @@ import { SSHTableComponent } from './table.component'
SSHInfoComponent, SSHInfoComponent,
RouterLink, RouterLink,
TitleDirective, TitleDirective,
TuiTable,
], ],
}) })
export class SettingsSSHComponent { export default class SystemSSHComponent {
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)

View File

@@ -7,6 +7,7 @@ import {
Input, Input,
} from '@angular/core' } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiTable } from '@taiga-ui/addon-table'
import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core' import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core'
import { import {
TuiConfirmData, TuiConfirmData,
@@ -24,11 +25,11 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
template: ` template: `
<thead> <thead>
<tr> <tr>
<th>Hostname</th> <th tuiTh>Hostname</th>
<th>Created At</th> <th tuiTh>Created At</th>
<th>Algorithm</th> <th tuiTh>Algorithm</th>
<th>Fingerprint</th> <th tuiTh>Fingerprint</th>
<th></th> <th tuiTh></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -108,7 +109,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
`, `,
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton], imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton, TuiTable],
}) })
export class SSHTableComponent { export class SSHTableComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core' import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({ @Component({
selector: 'wifi-info', selector: 'wifi-info',
@@ -9,6 +9,7 @@ import { TuiNotification } from '@taiga-ui/core'
and move the device anywhere you want. StartOS will automatically connect and move the device anywhere you want. StartOS will automatically connect
to available networks. to available networks.
<a <a
tuiLink
href="https://docs.start9.com/latest/user-manual/wifi" href="https://docs.start9.com/latest/user-manual/wifi"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -19,6 +20,6 @@ import { TuiNotification } from '@taiga-ui/core'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiNotification], imports: [TuiNotification, TuiLink],
}) })
export class WifiInfoComponent {} export class WifiInfoComponent {}

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiDialogOptions, TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiButton, TuiDialogOptions, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit' import { TuiBadge, TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { import {
FormComponent, FormComponent,
@@ -17,7 +17,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 { Wifi, WiFiForm, wifiSpec } from './utils' import { Wifi, WiFiForm, wifiSpec } from './utils'
import { SettingsWifiComponent } from './wifi.component' import SystemWifiComponent from './wifi.component'
@Component({ @Component({
selector: '[wifi]', selector: '[wifi]',
@@ -26,7 +26,7 @@ import { SettingsWifiComponent } from './wifi.component'
@if (network.ssid) { @if (network.ssid) {
<div tuiCell [style.padding]="0"> <div tuiCell [style.padding]="0">
<div tuiTitle> <div tuiTitle>
<strong> <strong tuiFade>
{{ network.ssid }} {{ network.ssid }}
@if (network.connected) { @if (network.connected) {
<tui-badge appearance="success">Connected</tui-badge> <tui-badge appearance="success">Connected</tui-badge>
@@ -34,12 +34,7 @@ import { SettingsWifiComponent } from './wifi.component'
</strong> </strong>
</div> </div>
@if (!network.connected) { @if (!network.connected) {
<button <button tuiButton size="xs" (click)="prompt(network)">
tuiButton
size="xs"
appearance="opposite"
(click)="prompt(network)"
>
Connect Connect
</button> </button>
} }
@@ -72,8 +67,12 @@ import { SettingsWifiComponent } from './wifi.component'
} }
} }
`, `,
host: { style: 'align-items: stretch' },
styles: ` styles: `
:host {
align-items: stretch;
white-space: nowrap;
}
tui-icon { tui-icon {
width: 2rem; width: 2rem;
color: var(--tui-text-tertiary); color: var(--tui-text-tertiary);
@@ -81,14 +80,22 @@ import { SettingsWifiComponent } from './wifi.component'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon], imports: [
CommonModule,
TuiCell,
TuiTitle,
TuiBadge,
TuiButton,
TuiIcon,
TuiFade,
],
}) })
export class WifiTableComponent { export class WifiTableComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly component = inject(SettingsWifiComponent) private readonly component = inject(SystemWifiComponent)
private readonly cdr = inject(ChangeDetectorRef) private readonly cdr = inject(ChangeDetectorRef)
@Input() @Input()

View File

@@ -23,6 +23,7 @@ import {
FormComponent, FormComponent,
FormContext, FormContext,
} from 'src/app/routes/portal/components/form.component' } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
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'
@@ -40,46 +41,51 @@ import { wifiSpec } from './wifi.const'
</ng-container> </ng-container>
<wifi-info /> <wifi-info />
@if (status()?.interface) { @if (status()?.interface) {
<h3 class="g-title"> <section class="g-card">
Wi-Fi <header>
<input Wi-Fi
type="checkbox" <input
tuiSwitch type="checkbox"
[ngModel]="status()?.enabled" tuiSwitch
(ngModelChange)="onToggle($event)" [style.margin-inline-start]="'auto'"
/> [ngModel]="status()?.enabled"
</h3> (ngModelChange)="onToggle($event)"
/>
@if (status()?.enabled) { </header>
@if (wifi(); as data) { @if (status()?.enabled) {
@if (data.known.length) { @if (wifi(); as data) {
<h3 class="g-title">Known Networks</h3> @if (data.known.length) {
<div tuiCardLarge tuiAppearance="neutral" [wifi]="data.known"></div> <p class="g-secondary">KNOWN NETWORKS</p>
<div
tuiCardLarge="compact"
tuiAppearance="neutral"
[wifi]="data.known"
[style.padding-block.rem]="0.5"
></div>
}
@if (data.available.length) {
<p class="g-secondary">OTHER NETWORKS</p>
<div
tuiCardLarge="compact"
tuiAppearance="neutral"
[wifi]="data.available"
[style.padding-block.rem]="0.5"
></div>
}
<p>
<button tuiButton size="s" (click)="other(data)">Add</button>
</p>
} @else {
<tui-loader [style.height.rem]="5" />
} }
@if (data.available.length) {
<h3 class="g-title">Other Networks</h3>
<div
tuiCardLarge
tuiAppearance="neutral"
[wifi]="data.available"
></div>
}
<p>
<button
tuiButton
size="s"
appearance="opposite"
(click)="other(data)"
>
Other...
</button>
</p>
} @else { } @else {
<tui-loader /> <app-placeholder icon="@tui.wifi">WiFi is disabled</app-placeholder>
} }
} </section>
} @else { } @else {
<p>No wireless interface detected.</p> <app-placeholder icon="@tui.wifi">
No wireless interface detected
</app-placeholder>
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -95,9 +101,10 @@ import { wifiSpec } from './wifi.const'
WifiTableComponent, WifiTableComponent,
TitleDirective, TitleDirective,
RouterLink, RouterLink,
PlaceholderComponent,
], ],
}) })
export class SettingsWifiComponent { export default class SystemWifiComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)

View File

@@ -1,18 +1,12 @@
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 { TitleDirective } from 'src/app/services/title.service'
import { SettingsMenuComponent } from './components/menu.component' import { SystemMenuComponent } from './components/menu.component'
@Component({ @Component({
template: ` template: `
<ng-container *title><span>Settings</span></ng-container> <ng-container *title><span>System System</span></ng-container>
<a <system-menu />
routerLink="/portal/settings"
routerLinkActive="_current"
[routerLinkActiveOptions]="{ exact: true }"
></a>
<settings-menu />
<router-outlet /> <router-outlet />
`, `,
styles: [ styles: [
@@ -26,22 +20,24 @@ import { SettingsMenuComponent } from './components/menu.component'
} }
} }
a,
span:not(:last-child), span:not(:last-child),
settings-menu { system-menu:not(:nth-last-child(2)) {
display: none; display: none;
} }
._current + settings-menu { system-menu,
router-outlet + ::ng-deep * {
display: flex; display: flex;
max-width: 30rem; flex-direction: column;
gap: 1rem;
margin: 0 auto; margin: 0 auto;
max-width: 45rem;
} }
`, `,
], ],
host: { class: 'g-page' }, host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [RouterModule, TuiIcon, SettingsMenuComponent, TitleDirective], imports: [RouterModule, SystemMenuComponent, TitleDirective],
}) })
export class SettingsComponent {} export class SystemComponent {}

View File

@@ -0,0 +1,46 @@
import { SystemComponent } from './system.component'
export default [
{
path: '',
component: SystemComponent,
children: [
{
path: 'acme',
loadComponent: () => import('./routes/acme/acme.component'),
},
{
path: 'email',
loadComponent: () => import('./routes/email/email.component'),
},
// {
// path: 'domains',
// loadComponent: () => import('./routes/domains/domains.component')
// },
// {
// path: 'proxies',
// loadComponent: () => import('./routes/proxies/proxies.component')
// },
// {
// path: 'router',
// loadComponent: () => import('./routes/router/router.component')
// },
{
path: 'wifi',
loadComponent: () => import('./routes/wifi/wifi.component'),
},
{
path: 'ui',
loadComponent: () => import('./routes/interfaces/ui.component'),
},
{
path: 'ssh',
loadComponent: () => import('./routes/ssh/ssh.component'),
},
{
path: 'sessions',
loadComponent: () => import('./routes/sessions/sessions.component'),
},
],
},
]

View File

@@ -5,33 +5,32 @@ import {
Injectable, Injectable,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { import {
TuiAlertService, TuiAlertService,
TuiDialogOptions, TuiDialogOptions,
TuiDialogService, TuiDialogService,
TuiLabel, TuiLabel,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import * as argon2 from '@start9labs/argon2' import { TUI_CONFIRM, TuiCheckbox, TuiConfirmData } from '@taiga-ui/kit'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiConfirmData, TUI_CONFIRM, TuiCheckbox } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { filter, firstValueFrom, from, take } from 'rxjs' import { filter, from, take } from 'rxjs'
import { switchMap } from 'rxjs/operators' import { switchMap } from 'rxjs/operators'
import { FormComponent } from 'src/app/routes/portal/components/form.component' import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component' import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { AuthService } from 'src/app/services/auth.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { ConfigService } from 'src/app/services/config.service'
import { getServerInfo } from 'src/app/utils/get-server-info'
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 { ApiService } from 'src/app/services/api/embassy-api.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { passwordSpec, PasswordSpec, SettingBtn } from './settings.types' import { passwordSpec, PasswordSpec, SettingBtn } from './system.types'
import { ConfigService } from 'src/app/services/config.service'
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsService { export class SystemService {
private readonly alerts = inject(TuiAlertService) private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
@@ -39,7 +38,6 @@ export class SettingsService {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly isTor = inject(ConfigService).isTor() private readonly isTor = inject(ConfigService).isTor()
wipe = false wipe = false
@@ -48,17 +46,10 @@ export class SettingsService {
General: [ General: [
{ {
title: 'Email', title: 'Email',
description: description: 'Connect to an external SMTP server for sending emails',
'Connect to an external SMTP server to send yourself emails',
icon: '@tui.mail', icon: '@tui.mail',
routerLink: 'email', routerLink: 'email',
}, },
{
title: 'Change Master Password',
description: `Change your StartOS master password`,
icon: '@tui.key',
action: () => this.promptNewPassword(),
},
], ],
Network: [ Network: [
// { // {
@@ -79,6 +70,19 @@ export class SettingsService {
// icon: '@tui.radio', // icon: '@tui.radio',
// routerLink: 'router', // routerLink: 'router',
// }, // },
{
title: 'User Interface Addresses',
description: 'View and manage your Start OS UI addresses',
icon: '@tui.monitor',
routerLink: 'ui',
},
{
title: 'ACME',
description:
'Add ACME providers to create SSL certificates for clearnet access',
icon: '@tui.award',
routerLink: 'acme',
},
{ {
title: 'WiFi', title: 'WiFi',
description: 'Add or remove WiFi networks', description: 'Add or remove WiFi networks',
@@ -92,27 +96,27 @@ export class SettingsService {
action: () => this.promptResetTor(), action: () => this.promptResetTor(),
}, },
], ],
'StartOS UI': [ Customize: [
{ {
title: 'Browser Tab Title', title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`, description: `Customize the display name of your browser tab`,
icon: '@tui.tag', icon: '@tui.tag',
action: () => this.setBrowserTab(), action: () => this.setBrowserTab(),
}, },
{
title: 'Web Addresses',
description: 'View and manage web addresses for accessing this UI',
icon: '@tui.monitor',
routerLink: 'ui',
},
], ],
'Privacy and Security': [ Security: [
// { // {
// title: 'Outbound Proxy', // title: 'Outbound Proxy',
// description: 'Proxy outbound traffic from the StartOS main process', // description: 'Proxy outbound traffic from the StartOS main process',
// icon: '@tui.shield', // icon: '@tui.shield',
// action: () => this.setOutboundProxy(), // action: () => this.setOutboundProxy(),
// }, // },
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: '@tui.clock',
routerLink: 'sessions',
},
{ {
title: 'SSH', title: 'SSH',
description: description:
@@ -121,33 +125,10 @@ export class SettingsService {
routerLink: 'ssh', routerLink: 'ssh',
}, },
{ {
title: 'Active Sessions', title: 'Change Password',
description: 'View and manage device access', description: `Change your StartOS master password`,
icon: '@tui.clock', icon: '@tui.key',
routerLink: 'sessions', action: () => this.promptNewPassword(),
},
],
Power: [
{
title: 'Restart',
icon: '@tui.refresh-cw',
description: 'Restart Start OS server',
action: () => this.promptPower('Restart'),
},
{
title: 'Shutdown',
icon: '@tui.power',
description: 'Turn Start OS server off',
action: () => this.promptPower('Shutdown'),
},
{
title: 'Logout',
icon: '@tui.log-out',
description: 'Log off from Start OS',
action: () => {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified()
},
}, },
], ],
} }
@@ -174,25 +155,6 @@ export class SettingsService {
.subscribe(() => this.resetTor(this.wipe)) .subscribe(() => this.resetTor(this.wipe))
} }
private async promptPower(action: 'Restart' | 'Shutdown') {
this.dialogs
.open(TUI_CONFIRM, getOptions(action))
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.api[
action === 'Restart' ? 'restartServer' : 'shutdownServer'
]({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
private async resetTor(wipeState: boolean) { private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe() const loader = this.loader.open('Resetting Tor...').subscribe()
@@ -340,31 +302,5 @@ export class SettingsService {
}) })
class WipeComponent { class WipeComponent {
readonly isTor = inject(ConfigService).isTor() readonly isTor = inject(ConfigService).isTor()
readonly service = inject(SettingsService) readonly service = inject(SystemService)
}
function getOptions(
operation: 'Restart' | 'Shutdown',
): Partial<TuiDialogOptions<TuiConfirmData>> {
return operation === 'Restart'
? {
label: 'Restart',
size: 's',
data: {
content:
'Are you sure you want to restart your server? It can take several minutes to come back online.',
yes: 'Restart',
no: 'Cancel',
},
}
: {
label: 'Warning',
size: 's',
data: {
content:
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
yes: 'Shutdown',
no: 'Cancel',
},
}
} }

View File

@@ -26,7 +26,7 @@ export class BadgeService {
private readonly notifications = inject(NotificationService) private readonly notifications = inject(NotificationService)
private readonly exver = inject(Exver) private readonly exver = inject(Exver)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly settings$ = combineLatest([ private readonly system$ = combineLatest([
this.patch.watch$('serverInfo', 'ntpSynced'), this.patch.watch$('serverInfo', 'ntpSynced'),
inject(EOSService).updateAvailable$, inject(EOSService).updateAvailable$,
]).pipe(map(([synced, update]) => Number(!synced) + Number(update))) ]).pipe(map(([synced, update]) => Number(!synced) + Number(update)))
@@ -83,8 +83,8 @@ export class BadgeService {
switch (id) { switch (id) {
// case '/portal/updates': // case '/portal/updates':
// return this.updates$ // return this.updates$
case '/portal/settings': case '/portal/system':
return this.settings$ return this.system$
case '/portal/notifications': case '/portal/notifications':
return this.notifications.unreadCount$ return this.notifications.unreadCount$
default: default:

View File

@@ -29,11 +29,7 @@ export class EOSService {
readonly updatingOrBackingUp$ = combineLatest([ readonly updatingOrBackingUp$ = combineLatest([
this.updating$, this.updating$,
this.backingUp$, this.backingUp$,
]).pipe( ]).pipe(map(([updating, backingUp]) => updating || backingUp))
map(([updating, backingUp]) => {
return updating || backingUp
}),
)
readonly showUpdate$ = combineLatest([ readonly showUpdate$ = combineLatest([
this.updateAvailable$, this.updateAvailable$,

View File

@@ -33,9 +33,9 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: '@tui.file-text', icon: '@tui.file-text',
title: 'Logs', title: 'Logs',
}, },
'/portal/settings': { '/portal/system': {
icon: '@tui.wrench', icon: '@tui.settings',
title: 'Settings', title: 'System',
}, },
'/portal/notifications': { '/portal/notifications': {
icon: '@tui.bell', icon: '@tui.bell',

View File

@@ -76,16 +76,16 @@ hr {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 3.25rem 1rem 0.5rem; padding: 3.125rem 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);
background-image: linear-gradient( background-image: linear-gradient(
to bottom, to bottom,
rgba(255, 255, 255, 0.15), var(--tui-background-neutral-2),
transparent transparent
), ),
linear-gradient(to bottom, rgba(255, 255, 255, 0.15), transparent); linear-gradient(to bottom, var(--tui-background-neutral-2), transparent);
background-size: 1px 100%; background-size: 1px 100%;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: background-position:
@@ -101,8 +101,13 @@ 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);
> [tuiCell] { &:is(form) {
margin: 0 -0.5rem; padding-top: 3.75rem;
}
[tuiCell] {
margin: 0 -0.625rem;
border-radius: var(--tui-radius-s);
&:not(:last-child)::after { &:not(:last-child)::after {
content: ''; content: '';
@@ -111,7 +116,7 @@ hr {
left: 1rem; left: 1rem;
right: 1rem; right: 1rem;
height: 1px; height: 1px;
background: var(--tui-border-normal); background: var(--tui-background-neutral-1);
} }
} }
@@ -129,11 +134,20 @@ hr {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
display: flex;
align-items: center;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--tui-background-neutral-1); background: var(--tui-background-neutral-1);
font: var(--tui-font-text-l); font: var(--tui-font-text-l);
font-weight: bold; font-weight: bold;
} }
> footer {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 1rem 0 0.5rem;
}
} }
.g-table:not([tuiTable]) { .g-table:not([tuiTable]) {