mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
task fix and keyboard fix (#3130)
* task fix and keyboard fix
* fixes for build scripts
* passthrough feature
* feat: inline domain health checks and improve address UX
- addPublicDomain returns DNS query + port check results (AddPublicDomainRes)
so frontend skips separate API calls after adding a domain
- addPrivateDomain returns check_dns result for the gateway
- Support multiple ports per domain in validation modal (deduplicated)
- Run port checks concurrently via futures::future::join_all
- Add note to add-domain dialog showing other interfaces on same host
- Add addXForwardedHeaders to knownProtocols in SDK Host.ts
- Add plugin filter kind, pluginId filter, matchesAny, and docs to
getServiceInterface.ts
- Add PassthroughInfo type and passthroughs field to NetworkInfo
- Pluralize "port forwarding rules" in i18n dictionaries
* feat: add shared host note to private domain dialog with i18n
* fix: scope public domain to single binding and return single port check
Accept internalPort in AddPublicDomainParams to target a specific
binding. Disable the domain on all other bindings. Return a single
CheckPortRes instead of Vec. Revert multi-port UI to singular port
display from 0f8a66b35.
* better shared hostname approach, and improve look-feel of addresses tables
* fix starttls
* preserve usb as top efi boot option
* fix race condition in wan ip check
* sdk beta.56
* various bug, improve smtp
* multiple bugs, better outbound gateway UX
* remove non option from smtp for better package compat
* bump sdk
---------
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
@@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
|
||||
@Component({
|
||||
template: `
|
||||
@if (!shuttingDown) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else if (drives.length === 0) {
|
||||
<p class="no-drives">
|
||||
{{
|
||||
'No drives found. Please connect a drive and click Refresh.' | i18n
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedOsDrive"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selectedOsDrive" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === true) {
|
||||
<tui-icon
|
||||
icon="@tui.database"
|
||||
style="color: var(--tui-status-positive); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === false) {
|
||||
<tui-icon
|
||||
icon="@tui.database-zap"
|
||||
style="color: var(--tui-status-negative); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #driveContent let-drive>
|
||||
<div class="drive-item">
|
||||
<span class="drive-name">
|
||||
{{ drive.vendor || ('Unknown' | i18n) }}
|
||||
{{ drive.model || ('Drive' | i18n) }}
|
||||
</span>
|
||||
<small>
|
||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else if (drives.length === 0) {
|
||||
<p class="no-drives">
|
||||
{{
|
||||
'No drives found. Please connect a drive and click Refresh.'
|
||||
| i18n
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
<tui-textfield
|
||||
[stringify]="stringify"
|
||||
[disabledItemHandler]="osDisabled"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[ngModel]="selectedOsDrive"
|
||||
(ngModelChange)="onOsDriveChange($event)"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[ngModel]="selectedOsDrive"
|
||||
(ngModelChange)="onOsDriveChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield
|
||||
[stringify]="stringify"
|
||||
[disabledItemHandler]="dataDisabled"
|
||||
>
|
||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === true) {
|
||||
<tui-icon
|
||||
icon="@tui.database"
|
||||
style="color: var(--tui-status-positive); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === false) {
|
||||
<tui-icon
|
||||
icon="@tui.database-zap"
|
||||
style="color: var(--tui-status-negative); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #driveContent let-drive>
|
||||
<div class="drive-item">
|
||||
<span class="drive-name">
|
||||
{{ driveName(drive) }}
|
||||
</span>
|
||||
<small>
|
||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@@ -198,6 +209,10 @@ export default class DrivesPage {
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
||||
)
|
||||
|
||||
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
|
||||
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
|
||||
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
shuttingDown = false
|
||||
@@ -206,10 +221,17 @@ export default class DrivesPage {
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
|
||||
readonly osDisabled = (drive: DiskInfo): boolean =>
|
||||
drive.capacity < this.MIN_OS
|
||||
|
||||
dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA
|
||||
|
||||
readonly driveName = (drive: DiskInfo): string =>
|
||||
[drive.vendor, drive.model].filter(Boolean).join(' ') ||
|
||||
this.i18n.transform('Unknown Drive')
|
||||
|
||||
readonly stringify = (drive: DiskInfo | null) =>
|
||||
drive
|
||||
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
|
||||
: ''
|
||||
drive ? this.driveName(drive) : ''
|
||||
|
||||
formatCapacity(bytes: number): string {
|
||||
const gb = bytes / 1e9
|
||||
@@ -231,6 +253,22 @@ export default class DrivesPage {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
onOsDriveChange(osDrive: DiskInfo | null) {
|
||||
this.selectedOsDrive = osDrive
|
||||
this.dataDisabled = (drive: DiskInfo) => {
|
||||
if (osDrive && drive.logicalname === osDrive.logicalname) {
|
||||
return drive.capacity < this.MIN_BOTH
|
||||
}
|
||||
return drive.capacity < this.MIN_DATA
|
||||
}
|
||||
|
||||
// Clear data drive if it's now invalid
|
||||
if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) {
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
}
|
||||
}
|
||||
|
||||
onDataDriveChange(drive: DiskInfo | null) {
|
||||
this.preserveData = null
|
||||
|
||||
@@ -400,7 +438,7 @@ export default class DrivesPage {
|
||||
|
||||
private async loadDrives() {
|
||||
try {
|
||||
this.drives = await this.api.getDisks()
|
||||
this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, inject, signal } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
getAllKeyboardsSorted,
|
||||
@@ -72,7 +71,6 @@ import { StateService } from '../services/state.service'
|
||||
],
|
||||
})
|
||||
export default class KeyboardPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
@@ -103,22 +101,9 @@ export default class KeyboardPage {
|
||||
})
|
||||
|
||||
this.stateService.keyboard = this.selected.layout
|
||||
await this.navigateToNextStep()
|
||||
await this.stateService.navigateAfterLocale()
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToNextStep() {
|
||||
if (this.stateService.dataDriveGuid) {
|
||||
if (this.stateService.attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
} else {
|
||||
await this.router.navigate(['/drives'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,12 @@ export default class LanguagePage {
|
||||
|
||||
try {
|
||||
await this.api.setLanguage({ language: this.selected.name })
|
||||
// Always go to keyboard selection
|
||||
await this.router.navigate(['/keyboard'])
|
||||
|
||||
if (this.stateService.kiosk) {
|
||||
await this.router.navigate(['/keyboard'])
|
||||
} else {
|
||||
await this.stateService.navigateAfterLocale()
|
||||
}
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,118 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
const GiB = 2 ** 30
|
||||
|
||||
const MOCK_DISKS: DiskInfo[] = [
|
||||
// 0 capacity - should be hidden entirely
|
||||
{
|
||||
logicalname: '/dev/sdd',
|
||||
vendor: 'Generic',
|
||||
model: 'Card Reader',
|
||||
partitions: [],
|
||||
capacity: 0,
|
||||
guid: null,
|
||||
},
|
||||
// 10 GiB - too small for OS and data; also tests both vendor+model null
|
||||
{
|
||||
logicalname: '/dev/sde',
|
||||
vendor: null,
|
||||
model: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sde1',
|
||||
label: null,
|
||||
capacity: 10 * GiB,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 10 * GiB,
|
||||
guid: null,
|
||||
},
|
||||
// 18 GiB - exact OS boundary; tests vendor null with model present
|
||||
{
|
||||
logicalname: '/dev/sdf',
|
||||
vendor: null,
|
||||
model: 'SATA Flash Drive',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdf1',
|
||||
label: null,
|
||||
capacity: 18 * GiB,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 18 * GiB,
|
||||
guid: null,
|
||||
},
|
||||
// 20 GiB - exact data boundary; tests vendor present with model null
|
||||
{
|
||||
logicalname: '/dev/sdg',
|
||||
vendor: 'PNY',
|
||||
model: null,
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdg1',
|
||||
label: null,
|
||||
capacity: 20 * GiB,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 20 * GiB,
|
||||
guid: null,
|
||||
},
|
||||
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
|
||||
{
|
||||
logicalname: '/dev/sdh',
|
||||
vendor: 'SanDisk',
|
||||
model: 'Ultra',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdh1',
|
||||
label: null,
|
||||
capacity: 30 * GiB,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 30 * GiB,
|
||||
guid: null,
|
||||
},
|
||||
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
|
||||
{
|
||||
logicalname: '/dev/sdi',
|
||||
vendor: 'Kingston',
|
||||
model: 'A400',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdi1',
|
||||
label: null,
|
||||
capacity: 30 * GiB,
|
||||
used: null,
|
||||
startOs: {
|
||||
'small-server-id': {
|
||||
hostname: 'small-server',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'small-existing-guid',
|
||||
},
|
||||
],
|
||||
capacity: 30 * GiB,
|
||||
guid: 'small-existing-guid',
|
||||
},
|
||||
// 500 GB - large, always OK
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'Samsung',
|
||||
@@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [
|
||||
capacity: 500000000000,
|
||||
guid: null,
|
||||
},
|
||||
// 1 TB with existing StartOS data
|
||||
{
|
||||
logicalname: '/dev/sdb',
|
||||
vendor: 'Crucial',
|
||||
@@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [
|
||||
capacity: 1000000000000,
|
||||
guid: 'existing-guid',
|
||||
},
|
||||
// 2 TB
|
||||
{
|
||||
logicalname: '/dev/sdc',
|
||||
vendor: 'WD',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { ApiService } from './api.service'
|
||||
|
||||
@@ -29,6 +30,7 @@ export type RecoverySource =
|
||||
})
|
||||
export class StateService {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
// Determined at app init
|
||||
kiosk = false
|
||||
@@ -45,6 +47,23 @@ export class StateService {
|
||||
setupType?: SetupType
|
||||
recoverySource?: RecoverySource
|
||||
|
||||
/**
|
||||
* Navigate to the appropriate step after language/keyboard selection.
|
||||
* Keyboard selection is only needed in kiosk mode.
|
||||
*/
|
||||
async navigateAfterLocale(): Promise<void> {
|
||||
if (this.dataDriveGuid) {
|
||||
if (this.attach) {
|
||||
this.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
} else {
|
||||
await this.router.navigate(['/drives'])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called for attach flow (existing data drive)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user