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'] {
border: 0;
backdrop-filter: blur(0.25rem);
border-radius: 0.325rem;
// TODO: Replace --tui-background-elevation-2 when Taiga UI is updated
background-color: color-mix(
in hsl,
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 0 1rem rgba(0, 0, 0, 0.25),
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 {

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 { RouterLink } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
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 { AuthService } from 'src/app/services/auth.service'
import { STATUS } from 'src/app/services/status.service'
@@ -28,34 +37,53 @@ import { ABOUT } from './about.component'
</div>
}
<tui-data-list [style.width.rem]="13">
<button tuiOption iconStart="@tui.info" (click)="about()">
About this server
</button>
<hr />
@for (link of links; track $index) {
<tui-opt-group>
<button tuiOption iconStart="@tui.info" (click)="about()">
About this server
</button>
</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
tuiOption
target="_blank"
rel="noreferrer"
[iconStart]="link.icon"
[href]="link.href"
iconStart="@tui.settings"
routerLink="/portal/system"
(click)="open = false"
>
{{ link.name }}
System Settings
</a>
}
<hr />
<a
tuiOption
iconStart="@tui.wrench"
routerLink="/portal/settings"
(click)="open = false"
>
System Settings
</a>
<hr />
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
Logout
</button>
</tui-opt-group>
<tui-opt-group label="">
<button
tuiOption
iconStart="@tui.refresh-cw"
(click)="promptPower('Restart')"
>
Restart
</button>
<button
tuiOption
iconStart="@tui.power"
(click)="promptPower('Shutdown')"
>
Shutdown
</button>
<button tuiOption iconStart="@tui.log-out" (click)="logout()">
Logout
</button>
</tui-opt-group>
</tui-data-list>
</ng-template>
`,
@@ -98,6 +126,8 @@ export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
open = false
@@ -108,8 +138,53 @@ export class HeaderMenuComponent {
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() {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
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({
standalone: true,
selector: 'service-placeholder',
selector: 'app-placeholder',
template: '<tui-icon [icon]="icon()" /><ng-content/>',
styles: `
:host {
@@ -26,6 +26,6 @@ import { TuiIcon } from '@taiga-ui/core'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon],
})
export class ServicePlaceholderComponent {
export class PlaceholderComponent {
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 { getMenu } from 'src/app/utils/system-utilities'
const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace']
const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
@Component({
standalone: true,
@@ -43,12 +43,12 @@ const FILTER = ['/portal/services', '/portal/settings', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.settings"
routerLink="/portal/settings"
routerLink="/portal/system"
routerLinkActive
[badge]="badge()"
(isActiveChange)="update()"
>
Settings
System
</a>
<button
tuiTabBarItem
@@ -140,7 +140,7 @@ export class TabsComponent {
readonly resources = RESOURCES
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,
})

View File

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

View File

@@ -7,6 +7,7 @@ import {
TuiIcon,
TuiButton,
TuiNotification,
TuiLink,
} from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
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
of your scheduled backups succeeds or fails.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/backups/backup-jobs"
target="_blank"
rel="noreferrer"
@@ -144,6 +146,7 @@ import { EDIT } from './edit.component'
ToHumanCronPipe,
GetBackupIconPipe,
TuiSkeleton,
TuiLink,
],
})
export class BackupsJobsModal implements OnInit {

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiTable } from '@taiga-ui/addon-table'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ServiceHealthCheckComponent } from './health-check.component'
import { ServicePlaceholderComponent } from './placeholder.component'
@Component({
standalone: true,
@@ -23,9 +23,9 @@ import { ServicePlaceholderComponent } from './placeholder.component'
</tbody>
</table>
@if (!checks().length) {
<service-placeholder icon="@tui.heart-pulse">
<app-placeholder icon="@tui.heart-pulse">
No health checks
</service-placeholder>
</app-placeholder>
}
`,
styles: `
@@ -35,7 +35,7 @@ import { ServicePlaceholderComponent } from './placeholder.component'
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceHealthCheckComponent, ServicePlaceholderComponent, TuiTable],
imports: [ServiceHealthCheckComponent, PlaceholderComponent, TuiTable],
})
export class ServiceHealthChecksComponent {
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 { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
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 { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -155,7 +155,6 @@ const ICONS = {
TitleDirective,
TuiButton,
],
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
})
export class ServiceOutletComponent {
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'
@Component({
selector: 'settings-sync',
selector: 'system-sync',
template: `
<tui-notification appearance="warning">
<div tuiCell [style.padding]="0">
@@ -31,4 +31,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
standalone: true,
imports: [TuiButton, TuiCell, TuiNotification, TuiTitle],
})
export class SettingsSyncComponent {}
export class SystemSyncComponent {}

View File

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

View File

@@ -46,7 +46,7 @@ import { EOSService } from 'src/app/services/eos.service'
TuiScrollbar,
],
})
export class SettingsUpdateModal {
export class SystemUpdateModal {
readonly versions = Object.entries(this.eosService.osUpdate?.releaseNotes!)
.sort(([a], [b]) => a.localeCompare(b))
.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 { toSignal } from '@angular/core/rxjs-interop'
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 { 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 { 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 { 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({
selector: 'acme',
template: ``,
styles: [],
template: `
<acme-info />
<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,
standalone: true,
imports: [CommonModule],
imports: [TuiButton, TuiLoader, TuiCell, TuiTitle, AcmeInfoComponent],
})
export class SettingsACMEComponent {
export default class SystemAcmeComponent {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme'
acme$ = this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme => {
const providerUrls = Object.keys(acme)
return providerUrls.map(url => {
const contact = acme[url].contact.map(mailto =>
mailto.replace('mailto:', ''),
)
return {
url,
contact,
contactString: contact.join(', '),
}
})
}),
acme = toSignal(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).map(url => {
const contact = acme[url].contact.map(mailto =>
mailto.replace('mailto:', ''),
)
return {
url,
contact,
contactString: contact.join(', '),
}
}),
),
),
)
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,
],
})
export class SettingsDomainsComponent {
export default class SystemDomainsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
@@ -103,7 +103,7 @@ export class SettingsDomainsComponent {
buttons: [
{
text: 'Manage proxies',
link: '/portal/settings/proxies',
link: '/portal/system/proxies',
},
{
text: 'Save',
@@ -128,7 +128,7 @@ export class SettingsDomainsComponent {
buttons: [
{
text: 'Manage proxies',
link: '/portal/settings/proxies',
link: '/portal/system/proxies',
},
{
text: 'Save',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import {
import {
DELETE_OPTIONS,
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 { FormDialogService } from 'src/app/services/form-dialog.service'
import { Proxy } from 'src/app/services/patch-db/data-model'

View File

@@ -1,12 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { CommonModule } from '@angular/common'
import { TuiNotification } from '@taiga-ui/core'
import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({
selector: 'router-info',
template: `
<tui-notification [appearance]="enabled ? 'positive' : 'warning'">
<ng-container *ngIf="enabled; else disabled">
@if (enabled) {
<strong>UPnP Enabled!</strong>
<p>
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
ports to suite your needs.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/port-forwards/upnp#override"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</ng-container>
<ng-template #disabled>
} @else {
<strong>UPnP Disabled</strong>
<p>
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
configuration.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/port-forwards/manual"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</ng-template>
}
</tui-notification>
`,
styles: ['strong { font-size: 1rem }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiNotification],
imports: [TuiNotification, TuiLink],
})
export class RouterInfoComponent {
@Input()

View File

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

View File

@@ -1,4 +1,5 @@
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
@@ -16,29 +17,34 @@ import { SSHTableComponent } from './table.component'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
Active Sessions
</ng-container>
<h3 class="g-title">Current session</h3>
<table
class="g-table"
[single]="true"
[sessions]="current$ | async"
></table>
<section class="g-card">
<header>Current session</header>
<table
tuiTable
class="g-table"
[single]="true"
[sessions]="current$ | async"
></table>
</section>
<ng-container *tuiLet="other$ | async as others">
<h3 class="g-title">
<section *tuiLet="other$ | async as others" class="g-card">
<header>
Other sessions
<button
*ngIf="table.selected$ | async as selected"
tuiButton
size="xs"
appearance="error"
[disabled]="!selected.length"
(click)="terminate(selected, others || [])"
>
Terminate selected
</button>
</h3>
<table #table class="g-table" [sessions]="others"></table>
</ng-container>
@if (table.selected$ | async; as selected) {
<button
tuiButton
size="xs"
appearance="negative"
[style.margin-inline-start]="'auto'"
[disabled]="!selected.length"
(click)="terminate(selected, others || [])"
>
Terminate selected
</button>
}
</header>
<table #table tuiTable class="g-table" [sessions]="others"></table>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
@@ -49,9 +55,10 @@ import { SSHTableComponent } from './table.component'
TuiLet,
RouterLink,
TitleDirective,
TuiTable,
],
})
export class SettingsSessionsComponent {
export default class SystemSessionsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
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 {
ChangeDetectionStrategy,
@@ -7,7 +6,9 @@ import {
OnChanges,
} from '@angular/core'
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 { Session } from 'src/app/services/api/api.types'
import { PlatformInfoPipe } from './platform-info.pipe'
@@ -17,7 +18,11 @@ import { PlatformInfoPipe } from './platform-info.pipe'
template: `
<thead>
<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) {
<input
tuiCheckbox
@@ -30,14 +35,14 @@ import { PlatformInfoPipe } from './platform-info.pipe'
}
User Agent
</th>
<th [style.width.%]="25">Platform</th>
<th [style.width.%]="25">Last Active</th>
<th tuiTh [style.width.%]="25">Platform</th>
<th tuiTh [style.width.%]="25">Last Active</th>
</tr>
</thead>
<tbody>
@for (session of sessions; track $index) {
<tr>
<td [style.padding-left.rem]="single ? null : 2">
<td [style.padding-left.rem]="single ? null : 2.25">
@if (!single) {
<input
tuiCheckbox
@@ -74,10 +79,14 @@ import { PlatformInfoPipe } from './platform-info.pipe'
`
@import '@taiga-ui/core/styles/taiga-ui-local';
td {
position: relative;
}
input {
position: absolute;
top: 50%;
left: 0.25rem;
left: 0.5rem;
transform: translateY(-50%);
}
@@ -118,12 +127,11 @@ import { PlatformInfoPipe } from './platform-info.pipe'
CommonModule,
FormsModule,
PlatformInfoPipe,
TuiButton,
TuiLink,
TuiIcon,
TuiCheckbox,
TuiFade,
TuiSkeleton,
TuiTable,
],
})
export class SSHTableComponent<T extends Session> implements OnChanges {

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiNotification } from '@taiga-ui/core'
import { TuiLink, TuiNotification } from '@taiga-ui/core'
@Component({
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
interfaces over clearnet.
<a
tuiLink
href="https://docs.start9.com/0.3.5.x/user-manual/ssh"
target="_blank"
rel="noreferrer"
@@ -18,6 +19,6 @@ import { TuiNotification } from '@taiga-ui/core'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotification],
imports: [TuiNotification, TuiLink],
})
export class SSHInfoComponent {}

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
FormComponent,
FormContext,
} 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 { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -40,46 +41,51 @@ import { wifiSpec } from './wifi.const'
</ng-container>
<wifi-info />
@if (status()?.interface) {
<h3 class="g-title">
Wi-Fi
<input
type="checkbox"
tuiSwitch
[ngModel]="status()?.enabled"
(ngModelChange)="onToggle($event)"
/>
</h3>
@if (status()?.enabled) {
@if (wifi(); as data) {
@if (data.known.length) {
<h3 class="g-title">Known Networks</h3>
<div tuiCardLarge tuiAppearance="neutral" [wifi]="data.known"></div>
<section class="g-card">
<header>
Wi-Fi
<input
type="checkbox"
tuiSwitch
[style.margin-inline-start]="'auto'"
[ngModel]="status()?.enabled"
(ngModelChange)="onToggle($event)"
/>
</header>
@if (status()?.enabled) {
@if (wifi(); as data) {
@if (data.known.length) {
<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 {
<tui-loader />
<app-placeholder icon="@tui.wifi">WiFi is disabled</app-placeholder>
}
}
</section>
} @else {
<p>No wireless interface detected.</p>
<app-placeholder icon="@tui.wifi">
No wireless interface detected
</app-placeholder>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -95,9 +101,10 @@ import { wifiSpec } from './wifi.const'
WifiTableComponent,
TitleDirective,
RouterLink,
PlaceholderComponent,
],
})
export class SettingsWifiComponent {
export default class SystemWifiComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)

View File

@@ -1,18 +1,12 @@
import { TuiIcon } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterModule } from '@angular/router'
import { TitleDirective } from 'src/app/services/title.service'
import { SettingsMenuComponent } from './components/menu.component'
import { SystemMenuComponent } from './components/menu.component'
@Component({
template: `
<ng-container *title><span>Settings</span></ng-container>
<a
routerLink="/portal/settings"
routerLinkActive="_current"
[routerLinkActiveOptions]="{ exact: true }"
></a>
<settings-menu />
<ng-container *title><span>System System</span></ng-container>
<system-menu />
<router-outlet />
`,
styles: [
@@ -26,22 +20,24 @@ import { SettingsMenuComponent } from './components/menu.component'
}
}
a,
span:not(:last-child),
settings-menu {
system-menu:not(:nth-last-child(2)) {
display: none;
}
._current + settings-menu {
system-menu,
router-outlet + ::ng-deep * {
display: flex;
max-width: 30rem;
flex-direction: column;
gap: 1rem;
margin: 0 auto;
max-width: 45rem;
}
`,
],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
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,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiAlertService,
TuiDialogOptions,
TuiDialogService,
TuiLabel,
} from '@taiga-ui/core'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiConfirmData, TUI_CONFIRM, TuiCheckbox } from '@taiga-ui/kit'
import { TUI_CONFIRM, TuiCheckbox, TuiConfirmData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
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 { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { AuthService } from 'src/app/services/auth.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
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 { ConfigService } from 'src/app/services/config.service'
import { passwordSpec, PasswordSpec, SettingBtn } from './system.types'
@Injectable({ providedIn: 'root' })
export class SettingsService {
export class SystemService {
private readonly alerts = inject(TuiAlertService)
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
@@ -39,7 +38,6 @@ export class SettingsService {
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly isTor = inject(ConfigService).isTor()
wipe = false
@@ -48,17 +46,10 @@ export class SettingsService {
General: [
{
title: 'Email',
description:
'Connect to an external SMTP server to send yourself emails',
description: 'Connect to an external SMTP server for sending emails',
icon: '@tui.mail',
routerLink: 'email',
},
{
title: 'Change Master Password',
description: `Change your StartOS master password`,
icon: '@tui.key',
action: () => this.promptNewPassword(),
},
],
Network: [
// {
@@ -79,6 +70,19 @@ export class SettingsService {
// icon: '@tui.radio',
// 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',
description: 'Add or remove WiFi networks',
@@ -92,27 +96,27 @@ export class SettingsService {
action: () => this.promptResetTor(),
},
],
'StartOS UI': [
Customize: [
{
title: 'Browser Tab Title',
description: `Customize the display name of your browser tab`,
icon: '@tui.tag',
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',
// description: 'Proxy outbound traffic from the StartOS main process',
// icon: '@tui.shield',
// action: () => this.setOutboundProxy(),
// },
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: '@tui.clock',
routerLink: 'sessions',
},
{
title: 'SSH',
description:
@@ -121,33 +125,10 @@ export class SettingsService {
routerLink: 'ssh',
},
{
title: 'Active Sessions',
description: 'View and manage device access',
icon: '@tui.clock',
routerLink: 'sessions',
},
],
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()
},
title: 'Change Password',
description: `Change your StartOS master password`,
icon: '@tui.key',
action: () => this.promptNewPassword(),
},
],
}
@@ -174,25 +155,6 @@ export class SettingsService {
.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) {
const loader = this.loader.open('Resetting Tor...').subscribe()
@@ -340,31 +302,5 @@ export class SettingsService {
})
class WipeComponent {
readonly isTor = inject(ConfigService).isTor()
readonly service = inject(SettingsService)
}
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',
},
}
readonly service = inject(SystemService)
}

View File

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

View File

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

View File

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

View File

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