mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
p {
|
||||
font-family: 'Courier New';
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
@@ -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 {}
|
||||
@@ -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 <email@example.com>
|
||||
<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)
|
||||
@@ -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 {}
|
||||
@@ -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>>(
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user