feat: support preferred external ports besides 443 (#3117)

* docs: update preferred external port design in TODO

* docs: add user-controlled public/private and port forward mapping to design

* docs: overhaul interfaces page design with view/manage split and per-address controls

* docs: move address enable/disable to overflow menu, add SSL indicator, defer UI placement decisions

* chore: remove tor from startos core

Tor is being moved from a built-in OS feature to a service. This removes
the Arti-based Tor client, onion address management, hidden service
creation, and all related code from the core backend, frontend, and SDK.

- Delete core/src/net/tor/ module (~2060 lines)
- Remove OnionAddress, TorSecretKey, TorController from all consumers
- Remove HostnameInfo::Onion and HostAddress::Onion variants
- Remove onion CRUD RPC endpoints and tor subcommand
- Remove tor key handling from account and backup/restore
- Remove ~12 tor-related Cargo dependencies (arti-client, torut, etc.)
- Remove tor UI components, API methods, mock data, and routes
- Remove OnionHostname and tor patterns/regexes from SDK
- Add v0_4_0_alpha_20 database migration to strip onion data
- Bump version to 0.4.0-alpha.20

* chore: flatten HostnameInfo from enum to struct

HostnameInfo only had one variant (Ip) after removing Tor. Flatten it
into a plain struct with fields gateway, public, hostname. Remove all
kind === 'ip' type guards and narrowing across SDK, frontend, and
container runtime. Update DB migration to strip the kind field.

* chore: format RPCSpec.md markdown table

* docs: update TODO.md with DerivedAddressInfo design, remove completed tor task

* feat: implement preferred port allocation and per-address enable/disable

- Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>)
- Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets
- Add Bindings wrapper with Map impl for patchdb indexed access
- Flatten HostAddress from single-variant enum to struct
- Replace set-gateway-enabled RPC with set-address-enabled
- Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible
- Compute possible addresses inline in NetServiceData::update()
- Update DB migration, SDK types, frontend, and container-runtime

* feat: replace InterfaceFilter with ForwardRequirements, add WildcardListener, complete alpha.20 bump

- Replace DynInterfaceFilter with ForwardRequirements for per-IP forward
  precision with source-subnet iptables filtering for private forwards
- Add WildcardListener (binds [::]:port) to replace the per-gateway
  NetworkInterfaceListener/SelfContainedNetworkInterfaceListener/
  UpgradableListener infrastructure
- Update forward-port script with src_subnet and excluded_src env vars
- Remove unused filter types and listener infrastructure from gateway.rs
- Add availablePorts migration (IdPool -> BTreeMap<u16, bool>) to alpha.20
- Complete version bump to 0.4.0-alpha.20 in SDK and web

* outbound gateway support (#3120)

* Multiple (#3111)

* fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete

* trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed

* Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112)

* Fix PackageInfoShort to handle LocaleString on releaseNotes

* fix: filter by target_version in get_matching_models and pass otherVersions from install

* chore: add exver documentation for ai agents

* frontend plus some be types

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* feat: replace SourceFilter with IpNet, add policy routing, remove MASQUERADE

* build ts types and fix i18n

* fix license display in marketplace

* wip refactor

* chore: update ts bindings for preferred port design

* feat: refactor NetService to watch DB and reconcile network state

- NetService sync task now uses PatchDB DbWatch instead of being called
  directly after DB mutations
- Read gateways from DB instead of network interface context when
  updating host addresses
- gateway sync updates all host addresses in the DB
- Add Watch<u64> channel for callers to wait on sync completion
- Fix ts-rs codegen bug with #[ts(skip)] on flattened Plugin field
- Update SDK getServiceInterface.ts for new HostnameInfo shape
- Remove unnecessary HTTPS redirect in static_server.rs
- Fix tunnel/api.rs to filter for WAN IPv4 address

* re-arrange (#3123)

* new service interfacee page

* feat: add mdns hostname metadata variant and fix vhost routing

- Add HostnameMetadata::Mdns variant to distinguish mDNS from private domains
- Mark mDNS addresses as private (public: false) since mDNS is local-only
- Fall back to null SNI entry when hostname not found in vhost mapping
- Simplify public detection in ProxyTarget filter
- Pass hostname to update_addresses for mDNS domain name generation

* looking good

* feat: add port_forwards field to Host for tracking gateway forwarding rules

* update bindings for API types, add ARCHITECTURE (#3124)

* update binding for API types, add ARCHITECTURE

* translations

* fix: add CONNMARK restore-mark to mangle OUTPUT chain

The CONNMARK --restore-mark rule was only in PREROUTING, which handles
forwarded packets. Locally-bound listeners (e.g. vhost) generate replies
through the OUTPUT chain, where the fwmark was never restored. This
caused response packets to route via the default table instead of back
through the originating interface.

* chore: reserialize db on equal version, update bindings and docs

- Run de/ser roundtrip in pre_init even when db version matches, ensuring
  all #[serde(default)] fields are populated before any typed access
- Add patchdb.md documentation for TypedDbWatch patterns
- Update TS bindings for CheckPortParams, CheckPortRes, ifconfigUrl
- Update CLAUDE.md docs with patchdb and component-level references

* fix: include public gateways for IP-based addresses in vhost targets

The server hostname vhost construction only collected private IPs,
always setting public to empty. Public IP addresses (Ipv4/Ipv6 metadata
with public=true) were never added to the vhost target's public gateway
set, causing the vhost filter to reject public traffic for IP-based
addresses.

* fix: add TLS handshake timeout and fix accept loop deadlock

Two issues in TlsListener::poll_accept:

1. No timeout on TLS handshakes: LazyConfigAcceptor waits indefinitely
   for ClientHello. Attackers that complete TCP handshake but never send
   TLS data create zombie futures in `in_progress` that never complete.
   Fix: wrap the entire handshake in tokio::time::timeout(15s).

2. Missing waker on new-connection pending path: when a TCP connection
   is accepted and the TLS handshake is pending, poll_accept returned
   Pending without calling wake_by_ref(). Since the TcpListener returned
   Ready (not Pending), no waker was registered for it. With edge-
   triggered epoll and no other wakeup source, the task sleeps forever
   and remaining connections in the kernel accept queue are never
   drained. Fix: add cx.waker().wake_by_ref() so the task immediately
   re-polls and continues draining the accept queue.

* fix: switch BackgroundJobRunner from Vec to FuturesUnordered

BackgroundJobRunner stored active jobs in a Vec<BoxFuture> and polled
ALL of them on every wakeup — O(n) per poll. Since this runs in the
same tokio::select! as the WebServer accept loop, polling overhead from
active connections directly delayed acceptance of new connections.

FuturesUnordered only polls woken futures — O(woken) instead of O(n).

* chore: update bindings and use typed params for outbound gateway API

* feat: per-service and default outbound gateway routing

Add set-outbound-gateway RPC for packages and set-default-outbound RPC
for the server, with policy routing enforcement via ip rules. Fix
connmark restore to skip packets with existing fwmarks, add bridge
subnet routes to per-interface tables, and fix squashfs path in
update-image-local.sh.

* refactor: manifest wraps PackageMetadata, move dependency_metadata to PackageVersionInfo

Manifest now embeds PackageMetadata via #[serde(flatten)] instead of
duplicating ~14 fields. icon and dependency_metadata moved from
PackageMetadata to PackageVersionInfo since they are registry-enrichment
data loaded from the S9PK archive. merge_with now returns errors on
metadata/icon/dependency_metadata mismatches instead of silently ignoring
them.

* fix: replace .status() with .invoke() for iptables/ip commands

Using .status() leaks stderr directly to system logs, causing noisy
iptables error messages. Switch all networking CLI invocations to use
.invoke() which captures stderr properly. For check-then-act patterns
(iptables -C), use .invoke().await.is_err() instead of
.status().await.map_or(false, |s| s.success()).

* feat: add check-dns gateway endpoint and fix per-interface routing tables

Add a `check-dns` RPC endpoint that verifies whether a gateway's DNS
is properly configured for private domain resolution. Uses a three-tier
check: direct match (DNS == server IP), TXT challenge probe (DNS on
LAN), or failure (DNS off-subnet).

Fix per-interface routing tables to clone all non-default routes from
the main table instead of only the interface's own subnets. This
preserves LAN reachability when the priority-75 catch-all overrides
default routing. Filter out status-only flags (linkdown, dead) that
are invalid for `ip route add`.

* refactor: rename manifest metadata fields and improve error display

Rename wrapperRepo→packageRepo, marketingSite→marketingUrl,
docsUrl→docsUrls (array), remove supportSite. Add display_src/display_dbg
helpers to Error. Fix DepInfo description type to LocaleString. Update
web UI, SDK bindings, tests, and fixtures to match. Clean up cli_attach
error handling and remove dead commented code.

* chore: bump sdk version to 0.4.0-beta.49

* chore: add createTask decoupling TODO

* chore: add TODO to clear service error state on install/update

* round out dns check, dns server check, port forward check, and gateway port forwards

* chore: add TODOs for URL plugins, NAT hairpinning, and start-tunnel OTA updates

* version instead of os query param

* interface row clickable again, bu now with a chevron!

* feat: implement URL plugins with table/row actions and prefill support

- Add URL plugin effects (register, export_url, clear_urls) in core
- Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types
- Implement plugin URL table in web UI with tableAction button and rowAction overflow menus
- Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions
- Add prefill support to PackageActionData so metadata passes through form dialogs
- Add i18n translations for plugin error messages
- Clean up plugin URLs on package uninstall

* feat: split row_actions into remove_action and overflow_actions for URL plugins

* touch up URL plugins table

* show table even when no addresses

* feat: NAT hairpinning, DNS static servers, clear service error on install

- Add POSTROUTING MASQUERADE rules for container and host hairpin NAT
- Allow bridge subnet containers to reach private forwards via LAN IPs
- Pass bridge_subnet env var from forward.rs to forward-port script
- Use DB-configured static DNS servers in resolver with DB watcher
- Fall back to resolv.conf servers when no static servers configured
- Clear service error state when install/update completes successfully
- Remove completed TODO items

* feat: builder-style InputSpec API, prefill plumbing, and port forward fix

- Add addKey() and add() builder methods to InputSpec with InputSpecTools
- Move OuterType to last generic param on Value, List, and all dynamic methods
- Plumb prefill through getActionInput end-to-end (core → container-runtime → SDK)
- Filter port_forwards to enabled addresses only
- Bump SDK to 0.4.0-beta.50

* fix: propagate host locale into LXC containers and write locale.conf

* chore: remove completed URL plugins TODO

* feat: OTA updates for start-tunnel via apt repository (untested)

- Add apt repo publish script (build/apt/publish-deb.sh) for S3-hosted repo
- Add apt source config and GPG key placeholder (apt/)
- Add tunnel.update.check and tunnel.update.apply RPC endpoints
- Wire up update API in tunnel frontend (api service + mock)
- Uses systemd-run --scope to survive service restart during update

* fix: publish script dpkg-name, s3cfg fallback, and --reinstall for apply

* chore: replace OTA updates TODO with UI TODO for MattDHill

* feat: add getOutboundGateway effect and simplify VersionGraph init/uninit

Add getOutboundGateway effect across core, container-runtime, and SDK
to let services query their effective outbound gateway with callback
support. Remove preInstall/uninstall hooks from VersionGraph as they
are no longer needed.

* frontend start-tunnel updates

* chore: remove completed TODO

* feat: tor hidden service key migration

* chore: migrate from ts-matches to zod across all TypeScript packages

* feat(core): allow setting server hostname

* send prefill for tasks and hide operations to hidden fields

* fix(core): preserve plugin URLs across binding updates

BindInfo::update was replacing addresses with a new DerivedAddressInfo
that cleared the available set, wiping plugin-exported URLs whenever
bind() was called. Also simplify update_addresses plugin preservation
to use retain in place rather than collecting into a separate set.

* minor cleanup from patch-db audit

* clean up prefill flow

* frontend support for setting and changing hostname

* feat(core): refactor hostname to ServerHostnameInfo with name/hostname pair

- Rename Hostname to ServerHostnameInfo, add name + hostname fields
- Add set_hostname_rpc for changing hostname at runtime
- Migrate alpha_20: generate serverInfo.name from hostname, delete ui.name
- Extract gateway.rs helpers to fix rustfmt nesting depth issue
- Add i18n key for hostname validation error
- Update SDK bindings

* add comments to everything potentially consumer facing (#3127)

* add comments to everything potentially consumer facing

* rework smtp

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* implement server name

* setup changes

* clean up copy around addresses table

* feat: add zod-deep-partial, partialValidator on InputSpec, and z.deepPartial re-export

* fix: header color in zoom (#3128)

* fix: merge version ranges when adding existing package signer (#3125)

* fix: merge version ranges when adding existing package signer

   Previously, add_package_signer unconditionally inserted the new
   version range, overwriting any existing authorization for that signer.
   Now it OR-merges the new range with the existing one, so running
   signer add multiple times accumulates permissions rather than
   replacing them.

* add --merge flag to registry package signer add

  Default behavior remains overwrite. When --merge is passed, the new
  version range is OR-merged with the existing one, allowing admins to
  accumulate permissions incrementally.

* add missing attribute to TS type

* make merge optional

* upsert instead of insert

* VersionRange::None on upsert

* fix: header color in zoom

---------

Co-authored-by: Dominion5254 <musashidisciple@proton.me>

* update snake and add about this server to system general

* chore: bump sdk to beta.53, wrap z.deepPartial with passthrough

* reset instead of reset defaults

* action failure show dialog

* chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering

- Bump SDK version to 0.4.0-beta.54
- Add `server.device-info` RPC endpoint and `s9pk select` CLI command
- Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering
- Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors
- Handle unhandled promise rejections in container-runtime with mute support
- Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values
- Accept readonly tuples in `CommandType` and `splitCommand`
- Remove `sync_host` calls from host API handlers (binding/address changes)
- Filter mDNS hostnames by secure gateway availability
- Derive mDNS enabled state from LAN IPs in web UI
- Add "Open UI" action to address table, disable mDNS toggle
- Hide debug details in service error component
- Update rpc-toolkit docs for no-params handlers

* fix: add --no-nvram to efi grub-install to preserve built-in boot order

* update snake

* diable actions when in error state

* chore: split out nvidia variant

* misc bugfixes

* create manage-release script (untested)

* fix: preserve z namespace types for sdk consumers

* sdk version bump

* new checkPort types

* multiple bugs and better port forward ux

* fix link

* chore: todos and formatting

* fix build

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Dominion5254 <musashidisciple@proton.me>
This commit is contained in:
Aiden McClelland
2026-03-04 04:37:31 -07:00
committed by GitHub
parent 26a68afdef
commit 3320391fcc
532 changed files with 21594 additions and 20559 deletions

View File

@@ -12,7 +12,7 @@ import { TuiCell } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { BackupReport } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { getManifest } from '../utils/get-package-data'
import { DataModel } from '../services/patch-db/data-model'
@@ -60,7 +60,7 @@ export class BackupsReportModal {
readonly data =
injectContext<
TuiDialogContext<void, { content: BackupReport; createdAt: string }>
TuiDialogContext<void, { content: T.BackupReport; createdAt: string }>
>().data
readonly pkgTitles = toSignal(

View File

@@ -69,7 +69,7 @@ export default class LogsPage implements OnInit {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
startCursor?: string
startCursor?: string | null
loading = false
logs: string[] = []
scrollTop = 0
@@ -98,7 +98,7 @@ export default class LogsPage implements OnInit {
try {
const response = await this.api.diagnosticGetLogs({
cursor: this.startCursor,
cursor: this.startCursor ?? undefined,
before: !!this.startCursor,
limit: 200,
})

View File

@@ -46,7 +46,7 @@
tuiButton
docsLink
size="s"
path="/user-manual/trust-ca.html"
path="/start-os/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}

View File

@@ -50,7 +50,12 @@ import { ABOUT } from './about.component'
</button>
</tui-opt-group>
<tui-opt-group label="" safeLinks>
<a tuiOption docsLink iconStart="@tui.book-open" path="/user-manual">
<a
tuiOption
docsLink
iconStart="@tui.book-open"
path="/start-os/user-manual/index.html"
>
{{ 'User manual' | i18n }}
</a>
<a

View File

@@ -5,7 +5,13 @@ import {
input,
signal,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import {
CopyService,
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -16,37 +22,71 @@ import {
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceAddressItemComponent } from './item.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { DomainHealthService } from './domain-health.service'
@Component({
selector: 'td[actions]',
template: `
<div class="desktop">
@if (interface.address().masked) {
<button
tuiIconButton
appearance="flat-grayscale"
[iconStart]="
interface.currentlyMasked() ? '@tui.eye' : '@tui.eye-off'
"
(click)="interface.currentlyMasked.set(!interface.currentlyMasked())"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
@if (interface.address().ui) {
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open' | i18n }}
{{ 'Open UI' | i18n }}
</a>
}
@if (address().deletable) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showPrivateDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().access === 'public' &&
address().hostnameInfo.port !== null
) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.settings"
(click)="showPortForwardValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
<button
tuiIconButton
appearance="flat-grayscale"
@@ -59,7 +99,7 @@ import { InterfaceAddressItemComponent } from './item.component'
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -75,30 +115,29 @@ import { InterfaceAddressItemComponent } from './item.component'
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (interface.address().ui) {
@if (address().ui) {
<a
tuiOption
new
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open' | i18n }}
{{ 'Open UI' | i18n }}
</a>
}
@if (interface.address().masked) {
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
interface.currentlyMasked.set(!interface.currentlyMasked())
"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button
tuiOption
new
[iconStart]="
address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'
"
(click)="toggleEnabled()"
>
{{ (address().enabled ? 'Disable' : 'Enable') | i18n }}
</button>
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@@ -106,10 +145,53 @@ import { InterfaceAddressItemComponent } from './item.component'
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showPrivateDnsValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().hostnameInfo.port !== null
) {
<button
tuiOption
new
iconStart="@tui.settings"
(click)="showPortForwardValidation()"
>
{{ 'Address Requirements' | i18n }}
</button>
}
@if (address().deletable) {
<button
tuiOption
new
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
</tui-data-list>
</button>
</div>
@@ -123,6 +205,11 @@ import { InterfaceAddressItemComponent } from './item.component'
white-space: nowrap;
}
.disabled {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}
.mobile {
display: none;
}
@@ -136,40 +223,130 @@ import { InterfaceAddressItemComponent } from './item.component'
display: block;
}
}
:host-context(tbody.uncommon-hidden) {
.desktop {
height: 0;
visibility: hidden;
}
.mobile {
display: none;
}
}
`,
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressActionsComponent {
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly domainHealth = inject(DomainHealthService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceAddressItemComponent)
readonly open = signal(false)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly disabled = input.required<boolean>()
readonly gatewayId = input('')
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: this.href(),
data: this.address().url,
})
.subscribe()
}
async toggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
showDnsValidation() {
const port = this.address().hostnameInfo.port
if (port === null) return
this.domainHealth.showPublicDomainSetup(
this.address().hostnameInfo.hostname,
this.gatewayId(),
port,
)
}
showPrivateDnsValidation() {
this.domainHealth.showPrivateDomainSetup(this.gatewayId())
}
showPortForwardValidation() {
const port = this.address().hostnameInfo.port
if (port === null) return
this.domainHealth.showPortForwardSetup(this.gatewayId(), port)
}
async deleteDomain() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const confirmed = await this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.toPromise()
if (!confirmed) return
const loader = this.loader.open('Removing').subscribe()
try {
const host = addr.hostnameInfo.hostname
if (addr.hostnameInfo.metadata.kind === 'public-domain') {
if (this.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn: host })
}
} else if (addr.hostnameInfo.metadata.kind === 'private-domain') {
if (this.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn: host })
}
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,117 +1,273 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiAccordion } from '@taiga-ui/experimental'
import { TuiElasticContainer, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MappedServiceInterface } from '../interface.service'
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 { toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import {
GatewayAddressGroup,
MappedServiceInterface,
} from '../interface.service'
import { DomainHealthService } from './domain-health.service'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'section[addresses]',
selector: 'section[gatewayGroup]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
<tui-elastic-container>
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
<th [style.width.rem]="2"></th>
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
@if (addresses()) {
<tr>
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
} @else {
@for (_ of [0, 1]; track $index) {
<tr>
<td colspan="6">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
<tbody [class.uncommon-hidden]="!uncommon">
@if (addresses()?.uncommon?.length && uncommon) {
<tr [style.background]="'var(--tui-background-neutral-1)'">
<td colspan="6"></td>
</tr>
}
@for (address of addresses()?.uncommon; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
}
</tbody>
@if (addresses()?.uncommon?.length) {
<caption [style.caption-side]="'bottom'">
<button
tuiButton
size="m"
appearance="secondary-grayscale"
(click)="uncommon = !uncommon"
>
@if (uncommon) {
Hide uncommon
} @else {
Show uncommon
}
</button>
</caption>
}
</table>
</tui-elastic-container>
<header>
{{ gatewayGroup().gatewayName }}
<button
tuiDropdown
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
[(tuiDropdownOpen)]="addOpen"
>
{{ 'Add Domain' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="addOpen.set(false)">
<button tuiOption new (click)="addPublicDomain()">
{{ 'Public Domain' | i18n }}
</button>
<button tuiOption new (click)="addPrivateDomain()">
{{ 'Private Domain' | i18n }}
</button>
</tui-data-list>
</button>
</header>
<table
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
>
@for (address of gatewayGroup().addresses; track $index) {
<tr
[address]="address"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
[gatewayId]="gatewayGroup().gatewayId"
></tr>
} @empty {
<tr>
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
:host ::ng-deep {
th:nth-child(2) {
th:first-child {
width: 5rem;
}
th:nth-child(3) {
width: 4rem;
}
}
.g-table:has(caption) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
[tuiButton] {
width: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:host-context(tui-root._mobile) {
[tuiButton] {
border-radius: var(--tui-radius-xs);
margin-block-end: 0.75rem;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiSkeleton,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
InterfaceAddressItemComponent,
TuiElasticContainer,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressesComponent {
readonly addresses = input.required<
MappedServiceInterface['addresses'] | undefined
>()
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
private readonly domainHealth = inject(DomainHealthService)
readonly gatewayGroup = input.required<GatewayAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
uncommon = false
readonly addOpen = signal(false)
async addPrivateDomain() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
size: 's',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async (value: { fqdn: string }) =>
this.savePrivateDomain(value.fqdn),
},
],
},
})
}
async addPublicDomain() {
const iface = this.value()
if (!iface) return
const network = await firstValueFrom(
this.patch.watch$('serverInfo', 'network'),
)
const authorities = Object.keys(network.acme).reduce<
Record<string, string>
>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
)
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...(iface.addSsl
? {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: authorities,
default: Object.keys(network.acme)[0] || 'local',
}),
}
: {}),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: (input: typeof addSpec._TYPE) =>
this.savePublicDomain(input.fqdn, input.authority),
},
],
},
})
}
private async savePrivateDomain(fqdn: string): Promise<boolean> {
const iface = this.value()
const gatewayId = this.gatewayGroup().gatewayId
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgAddPrivateDomain({
fqdn,
gateway: gatewayId,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId })
}
await this.domainHealth.checkPrivateDomain(gatewayId)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async savePublicDomain(
fqdn: string,
authority?: 'local' | string,
): Promise<boolean> {
const iface = this.value()
const gatewayId = this.gatewayGroup().gatewayId
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
if (this.packageId()) {
await this.api.pkgAddPublicDomain({
...params,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPublicDomain(params)
}
const port = this.gatewayGroup().addresses.find(
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,294 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { T } from '@start9labs/start-sdk'
import { parse } from 'tldts'
export type DnsGateway = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
export type DomainValidationData = {
fqdn: string
gateway: DnsGateway
port: number
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }
}
@Component({
selector: 'domain-validation',
template: `
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
<h2>{{ 'DNS' | i18n }}</h2>
<p>
{{ 'In your domain registrar for' | i18n }} {{ domain }},
{{ 'create this DNS record' | i18n }}
</p>
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
appearance="flat"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="dnsPass.set(undefined)"
/>
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="[null, 'Type', 'Host', 'Value', null]">
<tr>
<td class="status">
@if (dnsLoading()) {
<tui-loader size="s" />
} @else if (dnsPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (dnsPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ ddns ? 'ALIAS' : 'A' }}</td>
<td>*</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>
<button
tuiButton
size="s"
[loading]="dnsLoading()"
(click)="testDns()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
<port-check-icon [result]="portRes" [loading]="portLoading()" />
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button
tuiButton
size="s"
[loading]="portLoading()"
(click)="testPort()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<port-check-warnings [result]="portRes" />
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="allPass()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!allPass()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1.3rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'glass',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
TuiIcon,
TuiLoader,
PortCheckIconComponent,
PortCheckWarningsComponent,
],
})
export class DomainValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly ddns = false
readonly context =
injectContext<TuiDialogContext<void, DomainValidationData>>()
readonly domain =
parse(this.context.data.fqdn).domain || this.context.data.fqdn
readonly dnsLoading = signal(false)
readonly portLoading = signal(false)
readonly dnsPass = signal<boolean | undefined>(undefined)
readonly portResult = signal<T.CheckPortRes | undefined>(undefined)
readonly allPass = computed(() => {
const result = this.portResult()
return (
this.dnsPass() === true &&
!!result?.openInternally &&
!!result?.openExternally
)
})
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
this.dnsPass.set(initial.dnsPass)
if (initial.portResult) this.portResult.set(initial.portResult)
}
}
async testDns() {
this.dnsLoading.set(true)
try {
const ip = await this.api.queryDns({
fqdn: this.context.data.fqdn,
})
this.dnsPass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.dnsLoading.set(false)
}
}
async testPort() {
this.portLoading.set(true)
try {
const result = await this.api.checkPort({
gateway: this.context.data.gateway.id,
port: this.context.data.port,
})
this.portResult.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.portLoading.set(false)
}
}
}
export const DOMAIN_VALIDATION = new PolymorpheusComponent(
DomainValidationComponent,
)

View File

@@ -0,0 +1,190 @@
import { inject, Injectable } from '@angular/core'
import { DialogService, ErrorService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DOMAIN_VALIDATION, DnsGateway } from './dns.component'
import { PORT_FORWARD_VALIDATION } from './port-forward.component'
import { PRIVATE_DNS_VALIDATION } from './private-dns.component'
@Injectable({ providedIn: 'root' })
export class DomainHealthService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
async checkPublicDomain(
fqdn: string,
gatewayId: string,
port: number,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const [dnsPass, portResult] = await Promise.all([
this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null),
])
const portOk =
!!portResult?.openInternally &&
!!portResult?.openExternally &&
!!portResult?.hairpinning
if (!dnsPass || !portOk) {
setTimeout(
() =>
this.openPublicDomainModal(fqdn, gateway, port, {
dnsPass,
portResult,
}),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async checkPrivateDomain(gatewayId: string): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const configured = await this.api
.checkDns({ gateway: gatewayId })
.catch(() => false)
if (!configured) {
setTimeout(
() => this.openPrivateDomainModal(gateway, { configured }),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPublicDomainSetup(
fqdn: string,
gatewayId: string,
port: number,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPublicDomainModal(fqdn, gateway, port)
} catch (e: any) {
this.errorService.handleError(e)
}
}
async checkPortForward(gatewayId: string, port: number): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const portResult = await this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null)
const portOk =
!!portResult?.openInternally &&
!!portResult?.openExternally &&
!!portResult?.hairpinning
if (!portOk) {
setTimeout(
() => this.openPortForwardModal(gateway, port, { portResult }),
250,
)
}
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPortForwardSetup(gatewayId: string, port: number): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPortForwardModal(gateway, port)
} catch (e: any) {
this.errorService.handleError(e)
}
}
async showPrivateDomainSetup(gatewayId: string): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPrivateDomainModal(gateway)
} catch (e: any) {
this.errorService.handleError(e)
}
}
private async getGatewayData(gatewayId: string): Promise<DnsGateway | null> {
const network = await firstValueFrom(
this.patch.watch$('serverInfo', 'network'),
)
const gateway = network.gateways[gatewayId]
if (!gateway?.ipInfo) return null
return { id: gatewayId, ...gateway, ipInfo: gateway.ipInfo }
}
private openPublicDomainModal(
fqdn: string,
gateway: DnsGateway,
port: number,
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
) {
this.dialog
.openComponent(DOMAIN_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { fqdn, gateway, port, initialResults },
})
.subscribe()
}
private openPortForwardModal(
gateway: DnsGateway,
port: number,
initialResults?: { portResult: T.CheckPortRes | null },
) {
this.dialog
.openComponent(PORT_FORWARD_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { gateway, port, initialResults },
})
.subscribe()
}
private openPrivateDomainModal(
gateway: DnsGateway,
initialResults?: { configured: boolean },
) {
this.dialog
.openComponent(PRIVATE_DNS_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { gateway, initialResults },
})
.subscribe()
}
}

View File

@@ -6,121 +6,127 @@ import {
input,
signal,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { DisplayAddress } from '../interface.service'
import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
import { DomainHealthService } from './domain-health.service'
@Component({
selector: 'tr[address]',
host: {
'[class._disabled]': '!address().enabled',
},
template: `
@if (address(); as address) {
<td [style.padding-inline-end]="0">
<div class="wrapper">
<button
tuiIconButton
appearance="flat-grayscale"
(click)="viewDetails()"
<td>
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[disabled]="
toggling() || address.hostnameInfo.metadata.kind === 'mdns'
"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
</td>
<td class="type">
<tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/>
{{ address.type }}
</td>
<td>
{{ address.certificate }}
</td>
<td>
<div class="url">
<span
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ 'Address details' | i18n }}
<tui-icon
class="info"
icon="@tui.info"
background="@tui.info-filled"
/>
</button>
</div>
</td>
<td>
<div class="wrapper">{{ address.type }}</div>
</td>
<td>
<div class="wrapper">
@if (address.access === 'public') {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else if (address.access === 'private') {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
} @else {
-
{{ address.url | tuiObfuscate: recipe() }}
</span>
@if (address.masked) {
<button
tuiIconButton
appearance="flat-grayscale"
size="xs"
[iconStart]="currentlyMasked() ? '@tui.eye' : '@tui.eye-off'"
(click)="currentlyMasked.set(!currentlyMasked())"
>
{{ (currentlyMasked() ? 'Reveal' : 'Hide') | i18n }}
</button>
}
</div>
</td>
<td [style.grid-area]="'1 / 1 / 1 / 3'">
<div class="wrapper" [title]="address.gatewayName">
{{ address.gatewayName || '-' }}
</div>
</td>
<td [style.grid-area]="'3 / 1 / 3 / 3'">
<div
class="wrapper"
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</div>
</td>
<td
actions
[address]="address"
[packageId]="packageId()"
[value]="value()"
[disabled]="!isRunning()"
[href]="address.url"
[bullets]="address.bullets"
[gatewayId]="gatewayId()"
[style.width.rem]="5"
></td>
}
`,
styles: `
:host {
white-space: nowrap;
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
td:last-child {
padding-inline-start: 0;
}
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
.type tui-icon {
font-size: 1.3rem;
margin-right: 0.7rem;
vertical-align: middle;
}
:host-context(.uncommon-hidden) {
.wrapper {
height: 0;
visibility: hidden;
}
.url {
display: flex;
align-items: center;
gap: 0.25rem;
td,
& {
padding-block: 0 !important;
border: hidden;
span {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
}
div {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
:host-context(tui-root._mobile) {
padding-inline-start: 0.75rem !important;
&::before {
content: '';
position: absolute;
inset-inline-start: 0;
top: 0.25rem;
bottom: 0.25rem;
width: 4px;
background: var(--tui-status-positive);
border-radius: 2px;
}
&._disabled::before {
background: var(--tui-background-neutral-1-hover);
}
td {
width: auto !important;
align-content: center;
}
td:first-child {
grid-area: 1 / 3 / 4 / 3;
display: none;
}
td:nth-child(2) {
@@ -129,37 +135,104 @@ import { AddressActionsComponent } from './actions.component'
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
td:nth-child(3) {
grid-area: 2 / 1 / 2 / 3;
}
td:nth-child(4) {
grid-area: 3 / 1 / 3 / 3;
}
td:last-child {
grid-area: 1 / 3 / 4 / 5;
align-self: center;
justify-self: end;
}
}
`,
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiObfuscatePipe,
TuiButton,
TuiIcon,
TuiObfuscatePipe,
TuiSwitch,
FormsModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly domainHealth = inject(DomainHealthService)
readonly address = input.required<DisplayAddress>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
readonly gatewayId = input('')
readonly toggling = signal(false)
readonly currentlyMasked = signal(true)
readonly recipe = computed(() =>
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
viewDetails() {
this.dialogs
.openAlert(
`<ul>${this.address()
.bullets.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{ label: 'About this address' as i18nKey },
)
.subscribe()
async onToggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
this.toggling.set(true)
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
if (enabled) {
const kind = addr.hostnameInfo.metadata.kind
if (kind === 'public-domain' && addr.hostnameInfo.port !== null) {
await this.domainHealth.checkPublicDomain(
addr.hostnameInfo.hostname,
this.gatewayId(),
addr.hostnameInfo.port,
)
} else if (kind === 'private-domain') {
await this.domainHealth.checkPrivateDomain(this.gatewayId())
} else if (
kind === 'ipv4' &&
addr.access === 'public' &&
addr.hostnameInfo.port !== null
) {
await this.domainHealth.checkPortForward(
this.gatewayId(),
addr.hostnameInfo.port,
)
}
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.toggling.set(false)
}
}
}

View File

@@ -0,0 +1,361 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { ActionService } from 'src/app/services/action.service'
import {
MappedServiceInterface,
PluginAddress,
PluginAddressGroup,
} from '../interface.service'
@Component({
selector: 'section[pluginGroup]',
template: `
<header>
@if (pluginGroup().pluginPkgInfo; as pkgInfo) {
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
}
{{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="runTableAction()"
>
{{ action.metadata.name }}
</button>
}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index; let i = $index) {
<tr>
<td>{{ address.hostnameInfo.ssl ? 'HTTPS' : 'HTTP' }}</td>
<td [style.grid-area]="'2 / 1 / 2 / 2'">
<span class="url">{{ address.url }}</span>
</td>
<td [style.width.rem]="5">
<div class="desktop">
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
pluginGroup().pluginActions[
address.hostnameInfo.metadata.removeAction
];
as meta
) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.trash"
(click)="
runRowAction(
address.hostnameInfo.metadata.removeAction,
meta,
address
)
"
>
{{ meta.name }}
</button>
}
}
}
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.overflowActions.length) {
<button
tuiIconButton
tuiDropdown
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="overflowOpen() === i ? 'hover' : null"
[tuiDropdownOpen]="overflowOpen() === i"
(tuiDropdownOpenChange)="
overflowOpen.set($event ? i : null)
"
>
{{ 'More' | i18n }}
<tui-data-list
*tuiTextfieldDropdown
(click)="overflowOpen.set(null)"
>
@for (
actionId of address.hostnameInfo.metadata
.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
</tui-data-list>
</button>
}
}
</div>
<div class="mobile">
<button
tuiDropdown
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open() ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@if (address.hostnameInfo.metadata.removeAction) {
@if (
pluginGroup().pluginActions[
address.hostnameInfo.metadata.removeAction
];
as meta
) {
<button
tuiOption
new
iconStart="@tui.trash"
(click)="
runRowAction(
address.hostnameInfo.metadata.removeAction,
meta,
address
)
"
>
{{ meta.name }}
</button>
}
}
}
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.overflowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</tui-data-list>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
.plugin-icon {
height: 1.25rem;
margin-right: 0.25rem;
border-radius: 100%;
}
:host ::ng-deep {
th:first-child {
width: 5rem;
}
}
.desktop {
display: flex;
white-space: nowrap;
}
.url {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.mobile {
display: none;
}
:host-context(tui-root._mobile) {
.desktop {
display: none;
}
.mobile {
display: block;
}
tr {
grid-template-columns: 1fr auto;
}
td {
width: auto !important;
align-content: center;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PluginAddressesComponent {
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly actionService = inject(ActionService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
readonly overflowOpen = signal<number | null>(null)
readonly pluginGroup = input.required<PluginAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
showQR(url: string) {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: url,
})
.subscribe()
}
runTableAction() {
const group = this.pluginGroup()
if (!group.tableAction || !group.pluginPkgInfo) return
const iface = this.value()
if (!iface) return
const { addressInfo } = iface
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: group.tableAction,
prefill: {
urlPluginMetadata: {
packageId: this.packageId() || null,
hostId: addressInfo.hostId,
interfaceId: iface.id,
internalPort: addressInfo.internalPort,
},
},
})
}
runRowAction(
actionId: string,
metadata: T.ActionMetadata,
address: PluginAddress,
) {
const group = this.pluginGroup()
if (!group.pluginPkgInfo) return
const iface = this.value()
if (!iface) return
const { hostnameInfo } = address
const { addressInfo } = iface
const hostMeta = hostnameInfo.metadata
if (hostMeta.kind !== 'plugin') return
this.actionService.present({
pkgInfo: group.pluginPkgInfo,
actionInfo: { id: actionId, metadata },
prefill: {
urlPluginMetadata: {
packageId: this.packageId() || null,
hostId: addressInfo.hostId,
interfaceId: iface.id,
internalPort: addressInfo.internalPort,
hostname: hostnameInfo.hostname,
port: hostnameInfo.port,
ssl: hostnameInfo.ssl,
public: hostnameInfo.public,
info: hostMeta.info,
},
},
})
}
}

View File

@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DnsGateway } from './dns.component'
export type PortForwardValidationData = {
gateway: DnsGateway
port: number
initialResults?: { portResult: T.CheckPortRes | null }
}
@Component({
selector: 'port-forward-validation',
template: `
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
<port-check-icon [result]="portRes" [loading]="loading()" />
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testPort()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
<port-check-warnings [result]="portRes" />
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="portOk()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!portOk()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1.3rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiButtonLoading,
PortCheckIconComponent,
PortCheckWarningsComponent,
],
})
export class PortForwardValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context =
injectContext<TuiDialogContext<void, PortForwardValidationData>>()
readonly loading = signal(false)
readonly portResult = signal<T.CheckPortRes | undefined>(undefined)
readonly portOk = computed(() => {
const result = this.portResult()
return (
!!result?.openInternally &&
!!result?.openExternally &&
!!result?.hairpinning
)
})
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
if (initial.portResult) this.portResult.set(initial.portResult)
}
}
async testPort() {
this.loading.set(true)
try {
const result = await this.api.checkPort({
gateway: this.context.data.gateway.id,
port: this.context.data.port,
})
this.portResult.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const PORT_FORWARD_VALIDATION = new PolymorpheusComponent(
PortForwardValidationComponent,
)

View File

@@ -0,0 +1,180 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DnsGateway } from './dns.component'
export type PrivateDnsValidationData = {
gateway: DnsGateway
initialResults?: { configured: boolean }
}
@Component({
selector: 'private-dns-validation',
template: `
@let gatewayName =
context.data.gateway.name || context.data.gateway.ipInfo.name;
@let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n);
<h2>{{ 'DNS Server Config' | i18n }}</h2>
<p>
{{ 'Gateway' | i18n }} "{{ gatewayName }}"
{{ 'must be configured to use' | i18n }}
{{ internalIp }}
({{ 'the LAN IP address of this server' | i18n }})
{{ 'as its DNS server' | i18n }}.
</p>
<table [appTable]="[null, 'Gateway', 'DNS Server', null]">
<tr>
<td class="status">
@if (loading()) {
<tui-loader size="s" />
} @else if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ gatewayName }}</td>
<td>{{ internalIp }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="pass() === true"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="pass() !== true"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
styles: `
h2 {
margin: 2rem 0 0 0;
}
p {
margin-top: 0.5rem;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
.status {
width: 3.2rem;
}
.padding-top {
padding-top: 2rem;
}
td:last-child {
text-align: end;
}
footer {
margin-top: 1.5rem;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiButtonLoading,
TuiIcon,
TuiLoader,
],
})
export class PrivateDnsValidationComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context =
injectContext<TuiDialogContext<void, PrivateDnsValidationData>>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly isManualMode = !this.context.data.initialResults
constructor() {
const initial = this.context.data.initialResults
if (initial) {
this.pass.set(initial.configured)
}
}
async testDns() {
this.loading.set(true)
try {
const result = await this.api.checkDns({
gateway: this.context.data.gateway.id,
})
this.pass.set(result)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const PRIVATE_DNS_VALIDATION = new PolymorpheusComponent(
PrivateDnsValidationComponent,
)

View File

@@ -1,114 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
input,
inject,
} from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton, TuiSwitch, TuiTooltip } from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { i18nPipe, LoadingService, ErrorService } from '@start9labs/shared'
import { TuiCell } from '@taiga-ui/layout'
import { InterfaceGateway } from './interface.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[gateways]',
template: `
<header>{{ 'Gateways' | i18n }}</header>
@for (gateway of gateways(); track $index) {
<label tuiCell="s" [style.background]="">
<span tuiTitle [style.opacity]="1">{{ gateway.name }}</span>
@if (!interface.packageId() && !gateway.public) {
<tui-icon
[tuiTooltip]="
'Cannot disable private gateways for StartOS UI' | i18n
"
/>
}
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[ngModel]="gateway.enabled"
(ngModelChange)="onToggle(gateway)"
[disabled]="!interface.packageId() && !gateway.public"
/>
</label>
} @empty {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
`,
styles: `
:host {
grid-column: span 3;
}
[tuiCell]:has([tuiTooltip]) {
background: none !important;
}
:host-context(tui-root:not(._mobile)) {
&:has(+ section table) header {
background: transparent;
}
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
TuiSwitch,
i18nPipe,
TuiCell,
TuiTitle,
TuiSkeleton,
TuiIcon,
TuiTooltip,
],
})
export class InterfaceGatewaysComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly gateways = input.required<InterfaceGateway[] | undefined>()
async onToggle(gateway: InterfaceGateway) {
const addressInfo = this.interface.value()!.addressInfo
const pkgId = this.interface.packageId()
const loader = this.loader.open().subscribe()
try {
if (pkgId) {
await this.api.pkgBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: addressInfo.internalPort,
host: addressInfo.hostId,
package: pkgId,
})
} else {
await this.api.serverBindingToggleGateway({
gateway: gateway.id,
enabled: !gateway.enabled,
internalPort: 80,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,26 +1,27 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
import { PluginAddressesComponent } from './addresses/plugin.component'
@Component({
selector: 'service-interface',
template: `
<div>
<section [gateways]="value()?.gateways"></section>
@for (group of value()?.gatewayGroups; track group.gatewayId) {
<section
[publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false"
[gatewayGroup]="group"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
></section>
<section [torDomains]="value()?.torDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section>
</div>
<hr [style.width.rem]="10" />
<section [addresses]="value()?.addresses" [isRunning]="true"></section>
}
@for (group of value()?.pluginGroups; track group.pluginId) {
<section
[pluginGroup]="group"
[packageId]="packageId()"
[value]="value()"
></section>
}
`,
styles: `
:host {
@@ -30,33 +31,16 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
color: var(--tui-text-secondary);
font: var(--tui-font-text-l);
div {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: inherit;
flex-direction: column;
}
::ng-deep [tuiSkeleton] {
width: 100%;
height: 1rem;
border-radius: var(--tui-radius-s);
}
}
:host-context(tui-root._mobile) div {
display: flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
imports: [
InterfaceGatewaysComponent,
InterfaceTorDomainsComponent,
PublicDomainsComponent,
InterfacePrivateDomainsComponent,
InterfaceAddressesComponent,
],
imports: [InterfaceAddressesComponent, PluginAddressesComponent],
})
export class InterfaceComponent {
readonly packageId = input('')

View File

@@ -2,113 +2,91 @@ import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { toAuthorityName } from 'src/app/utils/acme'
import { getManifest } from 'src/app/utils/get-package-data'
type AddressWithInfo = {
url: string
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
masked: boolean
ui: boolean
function isPublicIp(h: T.HostnameInfo): boolean {
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
a: T,
b: T,
preds: ((x: T) => boolean)[],
): -1 | 0 | 1 {
for (const pred of preds) {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (pred(y) && !pred(x)) return sign
}
}
return 0
export function isLanIp(h: T.HostnameInfo): boolean {
return !h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
}
type TorAddress = AddressWithInfo & { info: { kind: 'onion' } }
function filterTor(a: AddressWithInfo): a is TorAddress {
return a.info.kind === 'onion'
}
function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [x => !x.showSsl])
}
type LanAddress = AddressWithInfo & { info: { kind: 'ip'; public: false } }
function filterLan(a: AddressWithInfo): a is LanAddress {
return a.info.kind === 'ip' && !a.info.public
}
function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'local', // .local
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type VpnAddress = AddressWithInfo & {
info: {
kind: 'ip'
public: false
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
if (isPublicIp(h)) {
if (h.port === null) return true
const sa =
h.metadata.kind === 'ipv6'
? `[${h.hostname}]:${h.port}`
: `${h.hostname}:${h.port}`
return addr.enabled.includes(sa)
} else {
return !addr.disabled.some(
([hostname, port]) => hostname === h.hostname && port === (h.port ?? 0),
)
}
}
function filterVpn(a: AddressWithInfo): a is VpnAddress {
return (
a.info.kind === 'ip' && !a.info.public && a.info.hostname.kind !== 'local'
)
}
function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type ClearnetAddress = AddressWithInfo & {
info: {
kind: 'ip'
public: true
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function getGatewayIds(h: T.HostnameInfo): string[] {
switch (h.metadata.kind) {
case 'ipv4':
case 'ipv6':
case 'public-domain':
return [h.metadata.gateway]
case 'mdns':
case 'private-domain':
return h.metadata.gateways
case 'plugin':
return []
}
}
function filterClearnet(a: AddressWithInfo): a is ClearnetAddress {
return a.info.kind === 'ip' && a.info.public
}
function cmpClearnet(
function getCertificate(
h: T.HostnameInfo,
host: T.Host,
a: ClearnetAddress,
b: ClearnetAddress,
): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
x.info.gateway.id === host.publicDomains[x.info.hostname.value]?.gateway, // public domain for this gateway
x => x.gateway?.public ?? false, // public gateway
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: private domains / domains public on other gateways
])
addSsl: T.AddSslOptions | null,
secure: T.Security | null,
): string {
if (!h.ssl) return '-'
if (h.metadata.kind === 'public-domain') {
const config = host.publicDomains[h.hostname]
return config ? toAuthorityName(config.acme) : toAuthorityName(null)
}
if (addSsl) return toAuthorityName(null)
if (secure?.ssl) return 'Self signed'
return '-'
}
export function getPublicDomains(
publicDomains: Record<string, T.PublicDomainConfig>,
gateways: GatewayPlus[],
): PublicDomain[] {
return Object.entries(publicDomains).map(([fqdn, info]) => ({
fqdn,
acme: info.acme,
gateway: gateways.find(g => g.id === info.gateway) || null,
}))
function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number {
const isDomain = (addr: GatewayAddress) =>
addr.hostnameInfo.metadata.kind === 'public-domain' ||
(addr.hostnameInfo.metadata.kind === 'private-domain' &&
!addr.hostnameInfo.hostname.endsWith('.local'))
return Number(isDomain(b)) - Number(isDomain(a))
}
function getAddressType(h: T.HostnameInfo): string {
switch (h.metadata.kind) {
case 'ipv4':
return 'IPv4'
case 'ipv6':
return 'IPv6'
case 'public-domain':
case 'private-domain':
return h.hostname
case 'mdns':
return 'mDNS'
case 'plugin':
return 'Plugin'
}
}
@Injectable({
@@ -116,95 +94,144 @@ export function getPublicDomains(
})
export class InterfaceService {
private readonly config = inject(ConfigService)
private readonly i18n = inject(i18nPipe)
getAddresses(
getGatewayGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
gateways: GatewayPlus[],
): MappedServiceInterface['addresses'] {
const hostnamesInfos = this.hostnameInfo(serviceInterface, host)
const addresses = {
common: [],
uncommon: [],
}
if (!hostnamesInfos.length) return addresses
): GatewayAddressGroup[] {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const { addSsl, secure } = binding.options
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(
h => {
const { url, sslUrl } = utils.addressHostToUrl(
serviceInterface.addressInfo,
h,
)
const info = h
const gateway =
h.kind === 'ip'
? gateways.find(g => h.gateway.id === g.id)
: undefined
const res = []
if (url) {
res.push({
url,
info,
gateway,
showSsl: false,
masked,
ui,
})
}
if (sslUrl) {
res.push({
url: sslUrl,
info,
gateway,
showSsl: !!url,
masked,
ui,
})
}
return res
},
)
const groupMap = new Map<string, GatewayAddress[]>()
const gatewayMap = new Map<string, GatewayPlus>()
const torAddrs = allAddressesWithInfo.filter(filterTor).sort(cmpTor)
const lanAddrs = allAddressesWithInfo
.filter(filterLan)
.sort((a, b) => cmpLan(host, a, b))
const vpnAddrs = allAddressesWithInfo
.filter(filterVpn)
.sort((a, b) => cmpVpn(host, a, b))
const clearnetAddrs = allAddressesWithInfo
.filter(filterClearnet)
.sort((a, b) => cmpClearnet(host, a, b))
let bestAddrs = [
(clearnetAddrs[0]?.gateway?.public ||
clearnetAddrs[0]?.info.hostname.kind === 'domain') &&
clearnetAddrs[0],
lanAddrs[0],
vpnAddrs[0],
torAddrs[0],
]
.filter(a => !!a)
.reduce((acc, x) => {
if (!acc.includes(x)) acc.push(x)
return acc
}, [] as AddressWithInfo[])
return {
common: bestAddrs.map(a => this.toDisplayAddress(a, host.publicDomains)),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => this.toDisplayAddress(a, host.publicDomains)),
for (const gateway of gateways) {
groupMap.set(gateway.id, [])
gatewayMap.set(gateway.id, gateway)
}
for (const h of addr.available) {
const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) {
const list = groupMap.get(gid)
if (!list) continue
list.push({
enabled: isEnabled(addr, h),
type: getAddressType(h),
access: h.public ? 'public' : 'private',
url: utils.addressHostToUrl(serviceInterface.addressInfo, h),
hostnameInfo: h,
masked,
ui,
deletable:
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain',
certificate: getCertificate(h, host, addSsl, secure),
})
}
}
return gateways
.filter(g => (groupMap.get(g.id)?.length ?? 0) > 0)
.map(g => {
const addresses = groupMap.get(g.id)!.sort(sortDomainsFirst)
// Derive mDNS enabled state from LAN IPs on this gateway
const lanIps = addresses.filter(a => isLanIp(a.hostnameInfo))
for (const a of addresses) {
if (a.hostnameInfo.metadata.kind === 'mdns') {
a.enabled = lanIps.some(ip => ip.enabled)
}
}
return {
gatewayId: g.id,
gatewayName: g.name,
addresses,
}
})
}
getPluginGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
allPackageData?: Record<string, T.PackageDataEntry>,
): PluginAddressGroup[] {
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const groupMap = new Map<string, PluginAddress[]>()
for (const h of addr.available) {
if (h.metadata.kind !== 'plugin') continue
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const pluginId = h.metadata.packageId
if (!groupMap.has(pluginId)) {
groupMap.set(pluginId, [])
}
groupMap.get(pluginId)!.push({
url,
hostnameInfo: h,
masked,
})
}
// Also include URL plugins that have no addresses yet
if (allPackageData) {
for (const [pkgId, pkg] of Object.entries(allPackageData)) {
if (pkg.plugin?.url && !groupMap.has(pkgId)) {
groupMap.set(pkgId, [])
}
}
}
return Array.from(groupMap.entries()).map(([pluginId, addresses]) => {
const pluginPkg = allPackageData?.[pluginId]
const pluginActions = pluginPkg?.actions ?? {}
const tableActionId = pluginPkg?.plugin?.url?.tableAction ?? null
const tableActionMeta = tableActionId
? pluginActions[tableActionId]
: undefined
const tableAction =
tableActionId && tableActionMeta
? { id: tableActionId, metadata: tableActionMeta }
: null
let pluginPkgInfo: PluginPkgInfo | null = null
if (pluginPkg) {
const manifest = getManifest(pluginPkg)
pluginPkgInfo = {
id: manifest.id,
title: manifest.title,
icon: pluginPkg.icon,
status: renderPkgStatus(pluginPkg).primary,
}
}
return {
pluginId,
pluginName:
pluginPkgInfo?.title ??
pluginId.charAt(0).toUpperCase() + pluginId.slice(1),
addresses,
tableAction,
pluginPkgInfo,
pluginActions,
}
})
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
const addresses = utils.filledAddress(host, ui.addressInfo)
@@ -214,9 +241,8 @@ export class InterfaceService {
kind: 'domain',
visibility: 'public',
})
const tor = addresses.filter({ kind: 'onion' })
const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' })
const bestPublic = [publicDomains, tor, wanIp].flatMap(h =>
const bestPublic = [publicDomains, wanIp].flatMap(h =>
h.format('urlstring'),
)[0]
const privateDomains = addresses.filter({
@@ -235,7 +261,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv4',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
onLan = true
@@ -244,7 +270,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv6',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.hostname === this.config.hostname,
})
.format('urlstring')[0]
break
@@ -254,9 +280,6 @@ export class InterfaceService {
.format('urlstring')[0]
onLan = true
break
case 'tor':
matching = tor.format('urlstring')[0]
break
case 'mdns':
matching = mdns.format('urlstring')[0]
onLan = true
@@ -268,237 +291,50 @@ export class InterfaceService {
if (bestPublic) return bestPublic
return ''
}
}
private hostnameInfo(
serviceInterface: T.ServiceInterface,
host: T.Host,
): T.HostnameInfo[] {
let hostnameInfo =
host.hostnameInfo[serviceInterface.addressInfo.internalPort]
return (
hostnameInfo?.filter(
h =>
this.config.accessType === 'localhost' ||
!(
h.kind === 'ip' &&
((h.hostname.kind === 'ipv6' &&
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) ||
h.gateway.id === 'lo')
),
) || []
)
}
export type GatewayAddress = {
enabled: boolean
type: string
access: 'public' | 'private'
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
ui: boolean
deletable: boolean
certificate: string
}
private toDisplayAddress(
{ info, url, gateway, showSsl, masked, ui }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
export type GatewayAddressGroup = {
gatewayId: string
gatewayName: string
addresses: GatewayAddress[]
}
const rootCaRequired = this.i18n.transform(
"Requires trusting your server's Root CA",
)
export type PluginAddress = {
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
}
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
this.i18n.transform('Connections can be slow or unreliable at times'),
this.i18n.transform(
'Public if you share the address publicly, otherwise private',
),
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (SSL)
if (showSsl) {
bullets = [rootCaRequired, ...bullets]
// Tor (NON-SSL)
} else {
bullets.unshift(
this.i18n.transform(
'Ideal for anonymous, censorship-resistant hosting and remote access',
),
)
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
gatewayName = info.gateway.name
export type PluginPkgInfo = {
id: string
title: string
icon: string
status: PrimaryStatus
}
const gatewayLanIpv4 = gateway?.lanIpv4[0]
const isWireguard = gateway?.ipInfo.deviceType === 'wireguard'
const localIdeal = this.i18n.transform('Ideal for local access')
const lanRequired = this.i18n.transform(
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN',
)
const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}`
const vpnAccess = this.i18n.transform('Ideal for VPN access via')
const routerWireguard = this.i18n.transform(
"your router's Wireguard server",
)
const portForwarding = this.i18n.transform(
'Requires port forwarding in gateway',
)
const dnsFor = this.i18n.transform('Requires a DNS record for')
const resolvesTo = this.i18n.transform('that resolves to')
// * Local *
if (info.hostname.kind === 'local') {
type = this.i18n.transform('Local')
access = 'private'
bullets = [
localIdeal,
this.i18n.transform(
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
),
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
this.i18n.transform('Can be used for clearnet access'),
this.i18n.transform(
'Not recommended in most cases. Using a public domain is more common and preferred',
),
rootCaRequired,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} ${routerWireguard}`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = [
this.i18n.transform('Can be used for local access'),
lanRequired,
rootCaRequired,
]
// * Domain *
} else {
type = this.i18n.transform('Domain')
if (info.public) {
access = 'public'
bullets = [
`${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway?.ipInfo.wanIp}`,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port === 443 ? 5443 : port}`,
)
}
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift(
this.i18n.transform('Ideal for public access via the Internet'),
)
} else {
bullets = [
this.i18n.transform(
'Can be used for personal access via the public Internet, but a VPN is more private and secure',
),
this.i18n.transform(
`Not good for public access, since the certificate is signed by your Server's Root CA`,
),
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = this.i18n.transform(
'when using IP addresses and ports is undesirable',
)
const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} ${routerWireguard} ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
if (showSsl) {
type = `${type} (SSL)`
bullets.unshift(
this.i18n.transform('Should only needed for apps that enforce SSL'),
)
}
return {
url,
access,
gatewayName,
type,
bullets,
masked,
ui,
}
}
export type PluginAddressGroup = {
pluginId: string
pluginName: string
addresses: PluginAddress[]
tableAction: { id: string; metadata: T.ActionMetadata } | null
pluginPkgInfo: PluginPkgInfo | null
pluginActions: Record<string, T.ActionMetadata>
}
export type MappedServiceInterface = T.ServiceInterface & {
gateways: InterfaceGateway[]
torDomains: string[]
publicDomains: PublicDomain[]
privateDomains: string[]
addresses: {
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[]
addSsl: boolean
}
export type InterfaceGateway = GatewayPlus & {
enabled: boolean
}
export type DisplayAddress = {
type: string
access: 'public' | 'private' | null
gatewayName: string | null
url: string
bullets: i18nKey[]
masked: boolean
ui: boolean
}

View File

@@ -1,184 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[privateDomains]',
template: `
<header>
{{ 'Private Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-locally.html"
fragment="private-domains"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of privateDomains(); track domain) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
[disabled]="!privateDomains()"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (privateDomains()) {
<app-placeholder icon="@tui.globe-lock">
{{ 'No private domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 4;
overflow-wrap: break-word;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfacePrivateDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly privateDomains = input.required<readonly string[] | undefined>()
async add() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.fqdn),
},
],
},
})
}
async remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
private async save(fqdn: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
if (this.interface.packageId) {
await this.api.pkgAddPrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,167 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
@Component({
selector: 'dns',
template: `
<p>{{ context.data.message }}</p>
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
/>
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
{{ ddns ? 'ALIAS' : 'A' }}
</td>
<td>{{ row.host }}</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>{{ row.purpose }}</td>
</tr>
}
</table>
<footer class="g-buttons">
<button tuiButton [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</footer>
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'primary',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
TuiIcon,
],
})
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly ddns = false
readonly context =
injectContext<
TuiDialogContext<
void,
{ fqdn: string; gateway: GatewayWithId; message: string }
>
>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly rows = computed<{ host: string; purpose: string }[]>(() => {
const { domain, subdomain } = parse(this.context.data.fqdn)
if (!subdomain) {
return [
{
host: '@',
purpose: domain!,
},
]
}
const segments = subdomain.split('.').slice(1)
const subdomains = this.i18n.transform('all subdomains of')
return [
{
host: subdomain,
purpose: `only ${subdomain}`,
},
...segments.map((_, i) => {
const parent = segments.slice(i).join('.')
return {
host: `*.${parent}`,
purpose: `${subdomains} ${parent}`,
}
}),
{
host: '*',
purpose: `${subdomains} ${domain}`,
},
]
})
async testDns() {
this.pass.set(undefined)
this.loading.set(true)
try {
const ip = await this.api.queryDns({
fqdn: this.context.data.fqdn,
})
this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)

View File

@@ -1,85 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { PublicDomainsItemComponent } from './pd.item.component'
import { PublicDomain, PublicDomainService } from './pd.service'
@Component({
selector: 'section[publicDomains]',
template: `
<header>
{{ 'Public Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
@if (service.data()) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add(addSsl())"
[disabled]="!publicDomains()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
@if (publicDomains()?.length === 0) {
<app-placeholder icon="@tui.globe">
{{ 'No public domains' | i18n }}
</app-placeholder>
} @else {
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [publicDomain]="domain" [addSsl]="addSsl()"></tr>
} @empty {
@for (_ of [0]; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
</table>
}
`,
styles: `
:host {
grid-column: span 7;
}
`,
host: { class: 'g-card' },
providers: [PublicDomainService],
imports: [
TuiButton,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
PublicDomainsItemComponent,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
readonly addSsl = input.required<boolean>()
}

View File

@@ -1,119 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { i18nPipe, i18nKey } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PublicDomain, PublicDomainService } from './pd.service'
import { toAuthorityName } from 'src/app/utils/acme'
@Component({
selector: 'tr[publicDomain]',
template: `
<td>{{ publicDomain().fqdn }}</td>
<td>{{ publicDomain().gateway?.name }}</td>
<td class="authority">{{ authority() }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
service.showDns(
publicDomain().fqdn,
publicDomain().gateway!,
dnsMessage()
)
"
>
{{ 'View DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit(publicDomain(), addSsl())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(publicDomain().fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
:host {
grid-template-columns: min-content 1fr min-content;
}
td:nth-child(2) {
order: -1;
grid-column: span 2;
}
td:last-child {
grid-area: 1 / 3 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
.authority {
grid-column: span 2;
}
tui-badge {
vertical-align: bottom;
margin-inline-start: 0.25rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield],
})
export class PublicDomainsItemComponent {
protected readonly service = inject(PublicDomainService)
open = false
readonly publicDomain = input.required<PublicDomain>()
readonly addSsl = input.required<boolean>()
readonly authority = computed(() =>
toAuthorityName(this.publicDomain().acme, this.addSsl()),
)
readonly dnsMessage = computed<i18nKey>(
() =>
`Create one of the DNS records below to cause ${this.publicDomain().fqdn} to resolve to ${this.publicDomain().gateway?.ipInfo.wanIp}` as i18nKey,
)
}

View File

@@ -1,277 +0,0 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
LoadingService,
i18nPipe,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { filter, map } from 'rxjs'
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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
export type PublicDomain = {
fqdn: string
gateway: GatewayWithId | null
acme: string | null
}
export type GatewayWithId = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
@Injectable()
export class PublicDomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, acme }) => ({
gateways: Object.entries(gateways)
.filter(([_, g]) => g.ipInfo)
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
async add(addSsl: boolean) {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) =>
this.save(input.fqdn, input.gateway, input.authority),
},
],
},
})
}
async edit(domain: PublicDomain, addSsl: boolean) {
const editSpec = ISB.InputSpec.of({
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Edit public domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: ({ gateway, authority }: typeof editSpec._TYPE) =>
this.save(domain.fqdn, gateway, authority),
},
],
value: {
gateway: domain.gateway!.id,
authority: domain.acme,
},
},
})
}
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records',
size: 'l',
data: {
fqdn,
gateway,
message,
},
})
.subscribe()
}
private async save(
fqdn: string,
gatewayId: string,
authority?: 'local' | string,
) {
const gateway = this.data()!.gateways.find(g => g.id === gatewayId)!
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
let ip: string | null
if (this.interface.packageId()) {
ip = await this.api.pkgAddPublicDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
ip = await this.api.osUiAddPublicDomain(params)
}
const wanIp = gateway.ipInfo.wanIp
let message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip !== wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog
.openAlert(
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
)
.subscribe(),
250,
)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewaySpec() {
const data = this.data()!
const gateways = data.gateways.filter(
({ ipInfo: { deviceType } }) =>
deviceType !== 'loopback' && deviceType !== 'bridge',
)
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: this.i18n.transform('Gateway'),
description: this.i18n.transform(
'Select a gateway to use for this domain.',
),
values: gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
[gateway.id]: gateway.name || gateway.ipInfo.name,
...obj,
}),
{ '~/system/gateways': this.i18n.transform('New gateway') },
),
default: '',
disabled: gateways
.filter(g => !g.ipInfo.wanIp || utils.CGNAT.contains(g.ipInfo.wanIp))
.map(g => g.id),
})),
}
}
private authoritySpec() {
const data = this.data()!
return {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: data.authorities,
default: '',
}),
}
}
}

View File

@@ -1,193 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
type OnionForm = {
key: string
}
@Component({
selector: 'section[torDomains]',
template: `
<header>
{{ 'Tor Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/tor.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
[disabled]="!torDomains()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of torDomains(); track domain) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (torDomains()) {
<app-placeholder icon="@tui.target">
{{ 'No Tor domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 6;
overflow-wrap: break-word;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly torDomains = input.required<readonly string[] | undefined>()
async remove(onion: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
const params = { onion }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
async add() {
this.formDialog.open<FormContext<OnionForm>>(FormComponent, {
label: 'New Tor domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Private Key (optional)')!,
description: this.i18n.transform(
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) domain. If not provided, a random key will be generated.',
),
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.key),
},
],
},
})
}
private async save(key?: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
const onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
if (this.interface.packageId()) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -3,10 +3,9 @@ import {
convertAnsi,
DownloadHTMLService,
ErrorService,
FetchLogsReq,
FetchLogsRes,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { LogsComponent } from './logs.component'
@Directive({
@@ -19,7 +18,7 @@ export class LogsDownloadDirective {
private readonly downloadHtml = inject(DownloadHTMLService)
@Input({ required: true })
logsDownload!: (params: FetchLogsReq) => Promise<FetchLogsRes>
logsDownload!: (params: T.LogsParams) => Promise<T.LogResponse>
@HostListener('click')
async download() {

View File

@@ -18,7 +18,7 @@ export class LogsFetchDirective {
switchMap(() =>
from(
this.component.fetchLogs({
cursor: this.component.startCursor,
cursor: this.component.startCursor ?? undefined,
before: true,
limit: 400,
}),

View File

@@ -5,11 +5,12 @@ import {
WaIntersectionObserver,
} from '@ng-web-apis/intersection-observer'
import { WaMutationObserver } from '@ng-web-apis/mutation-observer'
import { FetchLogsReq, FetchLogsRes, i18nPipe } from '@start9labs/shared'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiLoader, TuiScrollbar } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { BehaviorSubject } from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { FollowServerLogsReq } from 'src/app/services/api/api.types'
import { LogsDownloadDirective } from './logs-download.directive'
import { LogsFetchDirective } from './logs-fetch.directive'
import { LogsPipe } from './logs.pipe'
@@ -41,17 +42,17 @@ export class LogsComponent {
private readonly scrollbar?: ElementRef<HTMLElement>
@Input({ required: true }) followLogs!: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>
params: FollowServerLogsReq,
) => Promise<T.LogFollowResponse>
@Input({ required: true }) fetchLogs!: (
params: FetchLogsReq,
) => Promise<FetchLogsRes>
params: T.LogsParams,
) => Promise<T.LogResponse>
@Input({ required: true }) context!: string
scrollTop = 0
startCursor?: string
startCursor?: string | null
scroll = true
loading = false
previous: readonly string[] = []

View File

@@ -1,10 +1,6 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import {
convertAnsi,
i18nPipe,
Log,
toLocalIsoString,
} from '@start9labs/shared'
import { convertAnsi, i18nPipe, toLocalIsoString } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
bufferTime,
catchError,
@@ -25,7 +21,7 @@ import {
take,
tap,
} from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { FollowServerLogsReq } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { LogsComponent } from './logs.component'
@@ -40,9 +36,7 @@ export class LogsPipe implements PipeTransform {
private readonly i18n = inject(i18nPipe)
transform(
followLogs: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>,
followLogs: (params: FollowServerLogsReq) => Promise<T.LogFollowResponse>,
): Observable<readonly string[]> {
return merge(
this.logs.status$.pipe(
@@ -53,7 +47,7 @@ export class LogsPipe implements PipeTransform {
defer(() => followLogs(this.options)).pipe(
tap(r => this.logs.setCursor(r.startCursor)),
switchMap(r =>
this.api.openWebsocket$<Log>(r.guid, {
this.api.openWebsocket$<T.LogEntry>(r.guid, {
openObserver: {
next: () => this.logs.status$.next('connected'),
},

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
@Component({
selector: 'port-check-icon',
template: `
@if (loading()) {
<tui-loader size="s" />
} @else {
@let res = result();
@if (res) {
@if (!res.openInternally) {
<tui-icon class="g-warning" icon="@tui.alert-triangle" />
} @else if (!res.openExternally) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-positive" icon="@tui.check" />
}
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
}
`,
styles: `
tui-icon {
font-size: 1.3rem;
vertical-align: text-bottom;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, TuiLoader],
})
export class PortCheckIconComponent {
readonly result = input<T.CheckPortRes>()
readonly loading = input(false)
}

View File

@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@Component({
selector: 'port-check-warnings',
template: `
@let res = result();
@if (res) {
@if (!res.openInternally) {
<p class="g-warning">
{{
'Port status cannot be determined while service is not running'
| i18n
}}
</p>
}
@if (res.openExternally && !res.hairpinning) {
<p class="g-warning">
{{
'This address will not work from your local network due to a router hairpinning limitation'
| i18n
}}
</p>
}
}
`,
styles: `
p {
margin-top: 0.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [i18nPipe],
})
export class PortCheckWarningsComponent {
readonly result = input<T.CheckPortRes>()
}

View File

@@ -101,7 +101,7 @@ export class PortalComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$)
readonly bar = signal(true)

View File

@@ -15,7 +15,7 @@ import {
} from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
import { filter, map, Subject, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
@Component({
@@ -140,7 +140,7 @@ export class BackupsTargetsComponent {
readonly delete$ = new Subject<string>()
@Input()
backupsTargets: Record<string, BackupTarget> | null = null
backupsTargets: Record<string, T.BackupTarget> | null = null
@Output()
readonly update = new EventEmitter<string>()

View File

@@ -11,7 +11,8 @@ import {
import { TuiBadge, TuiSwitch } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { from, map } from 'rxjs'
import { BackupJob, BackupTarget } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { BackupJob } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
import { BackupJobBuilder } from '../utils/job-builder'
@@ -149,7 +150,7 @@ export class BackupsEditModal {
selectTarget() {
this.dialogs
.open<BackupTarget & { id: string }>(TARGET, TARGET_CREATE)
.open<T.BackupTarget & { id: string }>(TARGET, TARGET_CREATE)
.subscribe(({ id }) => {
this.job.targetId = id
})

View File

@@ -26,7 +26,9 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
Scheduling automatic backups is an excellent way to ensure your StartOS
data is safely backed up. StartOS will issue a notification whenever one
of your scheduled backups succeeds or fails.
<a tuiLink docsLink path="/@TODO">View instructions</a>
<a tuiLink docsLink path="/start-os/user-manual/backup-create.html">
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Saved Jobs

View File

@@ -8,7 +8,7 @@ import { TuiBlock, TuiCheckbox } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { take } from 'rxjs'
import { PackageBackupInfo } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ToOptionsPipe } from '../pipes/to-options.pipe'
@@ -98,7 +98,7 @@ export class BackupsRecoverModal {
}
}
get backups(): Record<string, PackageBackupInfo> {
get backups(): Record<string, T.PackageBackupInfo> {
return this.context.data.backupInfo.packageBackups
}
@@ -110,13 +110,12 @@ export class BackupsRecoverModal {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const loader = this.loader.open('Initializing').subscribe()
const { targetId, serverId, password } = this.context.data
const { targetId, password } = this.context.data
try {
await this.api.restorePackages({
ids,
targetId,
serverId,
password,
})

View File

@@ -6,7 +6,7 @@ import {
signal,
} from '@angular/core'
import { ErrorService, Exver } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import { T, Version } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDialogContext,
@@ -19,7 +19,6 @@ import {
import { TuiCell } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { BackupTarget } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getServerInfo } from 'src/app/utils/get-server-info'
@@ -81,7 +80,7 @@ export class BackupsTargetModal {
readonly context =
injectContext<
TuiDialogContext<BackupTarget & { id: string }, { type: BackupType }>
TuiDialogContext<T.BackupTarget & { id: string }, { type: BackupType }>
>()
readonly loading = signal(true)
@@ -91,7 +90,7 @@ export class BackupsTargetModal {
: 'Loading Backup Sources'
serverId = ''
targets: Record<string, BackupTarget> = {}
targets: Record<string, T.BackupTarget> = {}
async ngOnInit() {
try {
@@ -104,14 +103,14 @@ export class BackupsTargetModal {
}
}
isDisabled(target: BackupTarget): boolean {
isDisabled(target: T.BackupTarget): boolean {
return (
!target.mountable ||
(this.context.data.type === 'restore' && !this.hasBackup(target))
)
}
hasBackup(target: BackupTarget): boolean {
hasBackup(target: T.BackupTarget): boolean {
return (
target.startOs?.[this.serverId] &&
Version.parse(target.startOs[this.serverId].version).compare(
@@ -127,7 +126,7 @@ export class BackupsTargetModal {
.subscribe()
}
select(target: BackupTarget, id: string) {
select(target: T.BackupTarget, id: string) {
this.context.completeWith({ ...target, id })
}
}

View File

@@ -3,8 +3,8 @@ import { ErrorService, LoadingService } from '@start9labs/shared'
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 { T } from '@start9labs/start-sdk'
import {
BackupTarget,
BackupTargetType,
RR,
UnknownDisk,
@@ -31,7 +31,9 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
backups. They can be physical drives plugged into your server, shared
folders on your Local Area Network (LAN), or third party clouds such as
Dropbox or Google Drive.
<a tuiLink docsLink path="/@TODO">View instructions</a>
<a tuiLink docsLink path="/start-os/user-manual/backup-create.html">
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Unknown Physical Drives
@@ -188,7 +190,7 @@ export class BackupsTargetsModal implements OnInit {
}
private async add(
type: BackupTargetType,
type: T.BackupTargetType,
value:
| RR.AddCifsBackupTargetReq
| RR.AddCloudBackupTargetReq
@@ -204,7 +206,7 @@ export class BackupsTargetsModal implements OnInit {
}
private async update(
type: BackupTargetType,
type: T.BackupTargetType,
value:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
@@ -220,13 +222,13 @@ export class BackupsTargetsModal implements OnInit {
}
private setTargets(
saved: Record<string, BackupTarget> = this.targets()?.saved || {},
saved: Record<string, T.BackupTarget> = this.targets()?.saved || {},
unknownDisks: UnknownDisk[] = this.targets()?.unknownDisks || [],
) {
this.targets.set({ unknownDisks, saved })
}
private async getSpec(target: BackupTarget) {
private async getSpec(target: T.BackupTarget) {
switch (target.type) {
case 'cifs':
return await configBuilderToSpec(cifsSpec)

View File

@@ -1,5 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupTarget } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { DisplayInfo } from '../types/display-info'
import { GetBackupIconPipe } from './get-backup-icon.pipe'
@@ -9,7 +9,7 @@ import { GetBackupIconPipe } from './get-backup-icon.pipe'
export class GetDisplayInfoPipe implements PipeTransform {
readonly icon = new GetBackupIconPipe()
transform(target: BackupTarget): DisplayInfo {
transform(target: T.BackupTarget): DisplayInfo {
const result = {
name: target.name,
path: `Path: ${target.path}`,

View File

@@ -1,11 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupReport } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
@Pipe({
name: 'hasError',
})
export class HasErrorPipe implements PipeTransform {
transform(report: BackupReport): boolean {
transform(report: T.BackupReport): boolean {
return (
!!report.server.error ||
!!Object.values(report.packages).find(({ error }) => error)

View File

@@ -1,10 +1,9 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { map, Observable } from 'rxjs'
import { PackageBackupInfo } from 'src/app/services/api/api.types'
import { T, Version } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { RecoverOption } from '../types/recover-option'
import { Version } from '@start9labs/start-sdk'
@Pipe({
name: 'toOptions',
@@ -14,7 +13,7 @@ export class ToOptionsPipe implements PipeTransform {
transform(
packageData$: Observable<Record<string, PackageDataEntry>>,
packageBackups: Record<string, PackageBackupInfo> = {},
packageBackups: Record<string, T.PackageBackupInfo> = {},
): Observable<RecoverOption[]> {
return packageData$.pipe(
map(packageData =>

View File

@@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { from, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TARGET, TARGET_CREATE } from '../modals/target.component'
import { BACKUP, BACKUP_OPTIONS } from '../modals/backup.component'
@@ -17,7 +17,7 @@ export class BackupsCreateService {
readonly handle = () => {
this.dialogs
.open<BackupTarget & { id: string }>(TARGET, TARGET_CREATE)
.open<T.BackupTarget & { id: string }>(TARGET, TARGET_CREATE)
.pipe(
switchMap(({ id }) =>
this.dialogs

View File

@@ -22,7 +22,7 @@ import {
PROMPT,
PromptOptions,
} from 'src/app/routes/portal/modals/prompt.component'
import { BackupTarget } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { RECOVER } from '../modals/recover.component'
import { SERVERS } from '../modals/servers.component'
@@ -41,7 +41,7 @@ export class BackupsRestoreService {
readonly handle = () => {
this.dialogs
.open<BackupTarget & { id: string }>(TARGET, TARGET_RESTORE)
.open<T.BackupTarget & { id: string }>(TARGET, TARGET_RESTORE)
.pipe(
switchMap(target =>
this.dialogs

View File

@@ -1,8 +1,8 @@
import { BackupInfo } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
export interface RecoverData {
targetId: string
serverId: string
backupInfo: BackupInfo
backupInfo: T.BackupInfo
password: string
}

View File

@@ -1,6 +1,6 @@
import { PackageBackupInfo } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
export interface RecoverOption extends PackageBackupInfo {
export interface RecoverOption extends T.PackageBackupInfo {
id: string
checked: boolean
installed: boolean

View File

@@ -1,4 +1,5 @@
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { BackupJob, RR } from 'src/app/services/api/api.types'
export class BackupJobBuilder {
name: string

View File

@@ -13,10 +13,6 @@ export const ROUTES: Routes = [
path: 'os',
loadComponent: () => import('./routes/os.component'),
},
{
path: 'tor',
loadComponent: () => import('./routes/tor.component'),
},
]
export default ROUTES

View File

@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types'
import { FollowServerLogsReq } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { LogsHeaderComponent } from '../components/header.component'
@@ -24,9 +25,9 @@ import { LogsHeaderComponent } from '../components/header.component'
export default class SystemKernelComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
protected readonly follow = (params: FollowServerLogsReq) =>
this.api.followKernelLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
protected readonly fetch = (params: T.LogsParams) =>
this.api.getKernelLogs(params)
}

View File

@@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { FollowServerLogsReq } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
@@ -24,9 +25,9 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
export default class SystemOSComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
protected readonly follow = (params: FollowServerLogsReq) =>
this.api.followServerLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
protected readonly fetch = (params: T.LogsParams) =>
this.api.getServerLogs(params)
}

View File

@@ -79,12 +79,6 @@ export default class SystemLogsComponent {
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
},
{
link: 'tor',
title: 'Tor Logs',
subtitle: 'Diagnostics for the Tor daemon on this server',
icon: '@tui.target',
},
{
link: 'kernel',
title: 'Kernel Logs',

View File

@@ -1,32 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
template: `
<logs-header [title]="'Tor Logs' | i18n">
{{ 'Diagnostics for the Tor daemon on this server' | i18n }}
</logs-header>
<logs context="tor" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemTorComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followTorLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getTorLogs(params)
}

View File

@@ -4,7 +4,7 @@ import {
computed,
input,
} from '@angular/core'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { DataComponent } from './data.component'
import { i18nKey } from '@start9labs/shared'
@@ -86,7 +86,7 @@ const LABELS: Record<string, i18nKey> = {
imports: [DataComponent],
})
export class CpuComponent {
readonly value = input<ServerMetrics['cpu']>()
readonly value = input<T.Metrics['cpu']>()
readonly transform = computed(
(value = this.value()?.percentageUsed?.value || '0') =>

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'
import { TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ValuePipe } from './value.pipe'
import { i18nKey, i18nPipe } from '@start9labs/shared'
@@ -43,8 +43,8 @@ import { i18nKey, i18nPipe } from '@start9labs/shared'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiCell, TuiTitle, ValuePipe, i18nPipe],
})
export class DataComponent<T extends ServerMetrics[keyof ServerMetrics]> {
readonly labels = input.required<Record<keyof T, i18nKey>>()
readonly value = input<T>()
readonly keys = computed(() => Object.keys(this.labels()) as Array<keyof T>)
export class DataComponent<M extends T.Metrics[keyof T.Metrics]> {
readonly labels = input.required<Record<keyof M, i18nKey>>()
readonly value = input<M>()
readonly keys = computed(() => Object.keys(this.labels()) as Array<keyof M>)
}

View File

@@ -5,7 +5,7 @@ import {
input,
} from '@angular/core'
import { TuiProgress } from '@taiga-ui/kit'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { DataComponent } from './data.component'
import { ValuePipe } from './value.pipe'
import { i18nKey } from '@start9labs/shared'
@@ -40,7 +40,7 @@ const LABELS: Record<string, i18nKey> = {
imports: [DataComponent, TuiProgress, ValuePipe],
})
export class MemoryComponent {
readonly value = input<ServerMetrics['memory']>()
readonly value = input<T.Metrics['memory']>()
readonly used = computed(
(value = this.value()?.percentageUsed.value || '0') =>

View File

@@ -13,14 +13,14 @@ import {
take,
tap,
} from 'rxjs'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
@Injectable({
providedIn: 'root',
})
export class MetricsService extends Observable<ServerMetrics> {
export class MetricsService extends Observable<T.Metrics> {
private readonly connection = inject(ConnectionService)
private readonly api = inject(ApiService)
@@ -28,7 +28,7 @@ export class MetricsService extends Observable<ServerMetrics> {
this.api.followServerMetrics({}),
).pipe(
switchMap(({ guid, metrics }) =>
this.api.openWebsocket$<ServerMetrics>(guid).pipe(startWith(metrics)),
this.api.openWebsocket$<T.Metrics>(guid).pipe(startWith(metrics)),
),
catchError(() =>
this.connection.pipe(filter(Boolean), take(1), ignoreElements()),

View File

@@ -5,7 +5,7 @@ import {
input,
} from '@angular/core'
import { TuiProgress } from '@taiga-ui/kit'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { DataComponent } from './data.component'
import { i18nKey } from '@start9labs/shared'
@@ -41,7 +41,7 @@ const LABELS: Record<string, i18nKey> = {
imports: [TuiProgress, DataComponent],
})
export class StorageComponent {
readonly value = input<ServerMetrics['disk']>()
readonly value = input<T.Metrics['disk']>()
readonly used = computed(
(

View File

@@ -49,7 +49,7 @@ import { TimeService } from 'src/app/services/time.service'
docsLink
iconEnd="@tui.external-link"
appearance=""
path="/help/common-issues.html"
path="/start-os/faq/index.html"
fragment="#clock-sync-failure"
[pseudo]="true"
[textContent]="'the docs' | i18n"

View File

@@ -17,11 +17,8 @@ import {
import { TuiButton } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { distinctUntilChanged, skip } from 'rxjs/operators'
import {
RR,
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { ServerNotification } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BadgeService } from 'src/app/services/badge.service'
import { NotificationService } from 'src/app/services/notification.service'
@@ -66,7 +63,7 @@ export default class NotificationsComponent implements OnInit {
readonly service = inject(NotificationService)
readonly api = inject(ApiService)
readonly errorService = inject(ErrorService)
readonly notifications = signal<ServerNotifications | null>(null)
readonly notifications = signal<T.NotificationWithId[] | null>(null)
protected readonly table = viewChild<
NotificationsTableComponent<ServerNotification<number>>
@@ -92,7 +89,7 @@ export default class NotificationsComponent implements OnInit {
})
}
async getMore(params: RR.GetNotificationsReq) {
async getMore(params: T.ListNotificationParams) {
try {
this.notifications.set(null)
this.notifications.set(await this.api.getNotifications(params))
@@ -101,7 +98,7 @@ export default class NotificationsComponent implements OnInit {
}
}
async remove(all: ServerNotifications) {
async remove(all: T.NotificationWithId[]) {
const ids =
this.table()
?.selected()
@@ -119,7 +116,7 @@ export default class NotificationsComponent implements OnInit {
}
private init() {
this.getMore({}).then(() => {
this.getMore({ before: null, limit: null }).then(() => {
const latest = this.notifications()?.at(0)
if (latest) {
this.service.markSeenAll(latest.id)

View File

@@ -16,7 +16,6 @@ import { getManifest } from 'src/app/utils/get-package-data'
template: `
<header>{{ 'Service Launch Error' | i18n }}</header>
<p class="error-message">{{ error?.details }}</p>
<p>{{ error?.debug }}</p>
<h4>
{{ 'Actions' | i18n }}
<tui-icon [tuiTooltip]="hint" />

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
@@ -7,15 +8,19 @@ import { TuiBadge } from '@taiga-ui/kit'
template: `
<td><ng-content /></td>
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
<tui-badge size="m" [appearance]="appearance">
{{ info().type }}
</tui-badge>
</td>
<td class="g-secondary" [style.grid-area]="'2 / 1 / 2 / 3'">
{{ info.description }}
{{ info().description }}
</td>
<td class="chevron">
<tui-icon icon="@tui.chevron-right" />
</td>
`,
styles: `
:host {
clip-path: inset(0 round 0.75rem);
cursor: pointer;
&:hover {
@@ -32,13 +37,18 @@ import { TuiBadge } from '@taiga-ui/kit'
font-weight: bold;
}
tui-icon {
.chevron {
text-align: end;
}
.chevron tui-icon {
font-size: 1rem;
color: var(--tui-text-tertiary);
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content;
grid-template-columns: 1fr auto;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
@@ -46,17 +56,21 @@ import { TuiBadge } from '@taiga-ui/kit'
td {
padding: 0;
}
.chevron {
grid-area: 1 / 2 / 3 / 3;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge],
imports: [TuiBadge, TuiIcon],
})
export class ServiceInterfaceItemComponent {
@Input({ required: true })
info!: T.ServiceInterface
readonly info = input.required<T.ServiceInterface>()
readonly link = input.required<string>()
get appearance(): string {
switch (this.info.type) {
switch (this.info().type) {
case 'ui':
return 'positive'
case 'api':

View File

@@ -22,6 +22,7 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@@ -30,6 +31,7 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
tabindex="-1"
serviceInterface
[info]="info"
[link]="info.routerLink"
[routerLink]="info.routerLink"
>
<a [routerLink]="info.routerLink">
@@ -53,10 +55,10 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ServiceInterfaceItemComponent,
RouterLink,
TuiTable,
i18nPipe,
PlaceholderComponent,
RouterLink,
],
})
export class ServiceInterfacesComponent {

View File

@@ -19,7 +19,12 @@ import { ServiceTasksComponent } from 'src/app/routes/portal/routes/services/com
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
import {
ALLOWED_STATUSES,
getInstalledBaseStatus,
INACTIVE_STATUSES,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
@@ -49,6 +54,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
</td>
<td class="g-secondary" [style.grid-row]="3">
{{ task().reason || ('No reason provided' | i18n) }}
@if (disabled()) {
<div class="g-warning">{{ disabled() }}</div>
}
</td>
<td>
@if (task().severity !== 'critical') {
@@ -66,7 +74,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
tuiIconButton
iconStart="@tui.play"
appearance="primary-success"
[disabled]="!pkg()"
[disabled]="!!disabled()"
(click)="handle()"
>
{{ 'Run' | i18n }}
@@ -113,7 +121,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
}
`,
host: { '[style.opacity]': 'pkg() ? null : "var(--tui-disabled-opacity)"' },
host: {
'[style.opacity]': '!disabled() ? null : "var(--tui-disabled-opacity)"',
},
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiAvatar, i18nPipe, TuiFade],
})
@@ -124,6 +134,7 @@ export class ServiceTaskComponent {
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly tasks = inject(ServiceTasksComponent)
private readonly i18n = inject(i18nPipe)
readonly task = input.required<T.Task & { replayId: string }>()
readonly services = input.required<Record<string, PackageDataEntry>>()
@@ -135,6 +146,28 @@ export class ServiceTaskComponent {
() => this.tasks.pkg().currentDependencies[this.task().packageId],
)
readonly disabled = computed(() => {
const pkg = this.pkg()
if (!pkg) return this.i18n.transform('Not installed')!
const action = pkg.actions[this.task().actionId]
if (!action) return this.i18n.transform('Action not found')!
const status = renderPkgStatus(pkg).primary
if (INACTIVE_STATUSES.includes(status)) return status as string
if (!ALLOWED_STATUSES[action.allowedStatuses].has(status)) {
return `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`
}
if (typeof action.visibility === 'object') {
return action.visibility.disabled
}
return false
})
async dismiss() {
const { packageId, replayId } = this.task()
@@ -144,7 +177,7 @@ export class ServiceTaskComponent {
.subscribe(async () => {
const loader = this.loader.open().subscribe()
try {
await this.api.clearTask({ packageId, replayId })
await this.api.clearTask({ packageId, replayId, force: false })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -154,20 +187,21 @@ export class ServiceTaskComponent {
}
async handle() {
const task = this.task()
const title = this.title()
const pkg = this.pkg()
const metadata = pkg?.actions[this.task().actionId]
const metadata = pkg?.actions[task.actionId]
if (title && pkg && metadata) {
this.actionService.present({
pkgInfo: {
id: this.task().packageId,
id: task.packageId,
title,
status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon,
},
actionInfo: { id: this.task().actionId, metadata },
requestInfo: this.task(),
actionInfo: { id: task.actionId, metadata },
prefill: task.input?.value,
})
}
}

View File

@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiNotification } from '@taiga-ui/core'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PackageActionData } from './action-input.component'
@Component({
template: `
<div class="service-title">
<img [src]="pkgInfo.icon" alt="" />
<h4>{{ pkgInfo.title }}</h4>
</div>
<tui-notification appearance="warning">
<div [innerHTML]="warning"></div>
</tui-notification>
<footer class="g-buttons">
<button tuiButton appearance="flat" (click)="context.completeWith(false)">
{{ 'Cancel' | i18n }}
</button>
<button tuiButton (click)="context.completeWith(true)">
{{ 'Run' | i18n }}
</button>
</footer>
`,
styles: `
.service-title {
display: inline-flex;
align-items: center;
margin-bottom: 1.5rem;
img {
height: 1.25rem;
margin-right: 0.25rem;
border-radius: 100%;
}
h4 {
margin: 0;
}
}
footer {
margin-top: 1.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiNotification, i18nPipe],
})
export class ActionConfirmModal {
readonly context =
injectContext<TuiDialogContext<boolean, PackageActionData>>()
readonly pkgInfo = this.context.data.pkgInfo
readonly warning = this.context.data.actionInfo.metadata.warning
}
export const ACTION_CONFIRM_MODAL = new PolymorpheusComponent(
ActionConfirmModal,
)

View File

@@ -23,7 +23,6 @@ import {
FormComponent,
} from 'src/app/routes/portal/components/form.component'
import { InvalidService } from 'src/app/routes/portal/components/form/containers/control.directive'
import { TaskInfoComponent } from 'src/app/routes/portal/modals/config-dep.component'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -41,7 +40,7 @@ export type PackageActionData = {
id: string
metadata: T.ActionMetadata
}
requestInfo?: T.Task
prefill?: Record<string, unknown>
}
@Component({
@@ -62,13 +61,6 @@ export type PackageActionData = {
</tui-notification>
}
@if (requestInfo) {
<task-info
[originalValue]="res.originalValue || {}"
[operations]="res.operations || []"
/>
}
<app-form
[spec]="res.spec"
[value]="res.originalValue || {}"
@@ -81,7 +73,7 @@ export type PackageActionData = {
type="reset"
[style.margin-right]="'auto'"
>
{{ 'Reset defaults' | i18n }}
{{ 'Reset' | i18n }}
</button>
</app-form>
} @else if (!error()) {
@@ -109,14 +101,7 @@ export type PackageActionData = {
}
}
`,
imports: [
TuiNotification,
TuiLoader,
TuiButton,
TaskInfoComponent,
FormComponent,
i18nPipe,
],
imports: [TuiNotification, TuiLoader, TuiButton, FormComponent, i18nPipe],
providers: [InvalidService],
})
export class ActionInputModal {
@@ -131,7 +116,7 @@ export class ActionInputModal {
readonly actionId = this.context.data.actionInfo.id
readonly warning = this.context.data.actionInfo.metadata.warning
readonly pkgInfo = this.context.data.pkgInfo
readonly requestInfo = this.context.data.requestInfo
readonly prefill = this.context.data.prefill
eventId: string | null = null
buttons: ActionButton<any>[] = [
@@ -147,6 +132,7 @@ export class ActionInputModal {
this.api.getActionInput({
packageId: this.pkgInfo.id,
actionId: this.actionId,
prefill: this.prefill ?? null,
}),
).pipe(
map(res => {
@@ -154,18 +140,20 @@ export class ActionInputModal {
const originalValue = res.value || {}
this.eventId = res.eventId
const operations = this.prefill
? compare(
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
JSON.parse(JSON.stringify(originalValue)),
this.prefill,
) as object,
)
: null
return {
spec: res.spec,
originalValue,
operations: this.requestInfo?.input
? compare(
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.input.value,
) as object,
)
: null,
operations,
}
}),
catchError(e => {

View File

@@ -1,6 +1,6 @@
import { RR } from 'src/app/services/api/api.types'
import { ActionRes } from 'src/app/services/api/api.types'
type ActionResponse = NonNullable<RR.ActionRes>
type ActionResponse = NonNullable<ActionRes>
type ActionResult = NonNullable<ActionResponse['result']>
export type ActionResponseWithResult = ActionResponse & { result: ActionResult }
export type SingleResult = ActionResult & { type: 'single' }

View File

@@ -116,6 +116,23 @@ export default class ServiceAboutRoute {
},
],
},
{
header: 'Links',
items: [
...manifest.docsUrls.map(docsUrl => ({
name: 'Documentation',
value: docsUrl,
})),
{
name: 'Marketing',
value: manifest.marketingUrl || NOT_PROVIDED,
},
{
name: 'Donations',
value: manifest.donationUrl || NOT_PROVIDED,
},
],
},
{
header: 'Source Code',
items: [
@@ -125,28 +142,7 @@ export default class ServiceAboutRoute {
},
{
name: 'StartOS package',
value: manifest.wrapperRepo,
},
],
},
{
header: 'Links',
items: [
{
name: 'Documentation',
value: manifest.docsUrl || NOT_PROVIDED,
},
{
name: 'Support',
value: manifest.supportSite || NOT_PROVIDED,
},
{
name: 'Marketing',
value: manifest.marketingSite || NOT_PROVIDED,
},
{
name: 'Donations',
value: manifest.donationUrl || NOT_PROVIDED,
value: manifest.packageRepo,
},
],
},

View File

@@ -6,43 +6,31 @@ import {
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
ErrorService,
getPkgId,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { firstValueFrom, map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
import {
ALLOWED_STATUSES,
INACTIVE_STATUSES,
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
const INACTIVE: PrimaryStatus[] = [
'installing',
'updating',
'removing',
'restoring',
'backing-up',
]
const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
'only-running': new Set(['running']),
'only-stopped': new Set(['stopped']),
any: new Set([
'running',
'stopped',
'restarting',
'restoring',
'stopping',
'starting',
'backing-up',
'task-required',
]),
}
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@Component({
template: `
@@ -65,6 +53,12 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
<section class="g-card">
<header>StartOS</header>
<button
tuiCell
[action]="outboundGatewayAction()"
[inactive]="inactive"
(click)="openOutboundGatewayModal()"
></button>
<button
tuiCell
[action]="rebuild"
@@ -95,66 +89,89 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
export default class ServiceActionsRoute {
private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService)
readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
const status = renderPkgStatus(pkg).primary
return {
status,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.filter(([_, action]) => action.visibility !== 'hidden')
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(
status,
)
? action.visibility
: ({
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
} as T.ActionVisibility),
}))
.sort((a, b) => {
if (a.group === specialGroup && b.group !== specialGroup)
return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
private readonly gateways = toSignal(
this.patch.watch$('serverInfo', 'network', 'gateways'),
)
readonly package = toSignal(
this.patch.watch$('packageData', getPkgId()).pipe(
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
? 'Other'
: 'General'
const status = renderPkgStatus(pkg).primary
return {
status,
icon: pkg.icon,
manifest: getManifest(pkg),
outboundGateway: pkg.outboundGateway,
actions: Object.entries(pkg.actions)
.filter(([_, action]) => action.visibility !== 'hidden')
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(status)
? action.visibility
: ({
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
} as T.ActionVisibility),
}))
.sort((a, b) => {
if (a.group === specialGroup && b.group !== specialGroup) return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
)
readonly outboundGatewayAction = computed(() => {
const pkg = this.package()
const gatewayId = pkg?.outboundGateway
const gateways = this.gateways()
const gatewayName =
gatewayId && gateways?.[gatewayId]
? (gateways[gatewayId].name ??
gateways[gatewayId].ipInfo?.name ??
gatewayId)
: null
return {
name: this.i18n.transform('Set Outbound Gateway')!,
description: gatewayName
? `${this.i18n.transform('Current')}: ${gatewayName}`
: `${this.i18n.transform('Current')}: ${this.i18n.transform('System')}`,
}
})
readonly rebuild = {
name: this.i18n.transform('Rebuild Service')!,
description: this.i18n.transform(
@@ -181,7 +198,72 @@ export default class ServiceActionsRoute {
})
}
async openOutboundGatewayModal() {
const pkg = this.package()
if (!pkg) return
const gateways = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'gateways'),
)
const SYSTEM_KEY = 'system'
const options: Record<string, string> = {
[SYSTEM_KEY]: this.i18n.transform('System default')!,
}
Object.entries(gateways)
.filter(
([_, g]) =>
!!g.ipInfo &&
g.ipInfo.deviceType !== 'bridge' &&
g.ipInfo.deviceType !== 'loopback',
)
.forEach(([id, g]) => {
options[id] = g.name ?? g.ipInfo?.name ?? id
})
const spec = ISB.InputSpec.of({
gateway: ISB.Value.select({
name: this.i18n.transform('Outbound Gateway'),
description: this.i18n.transform(
'Select the gateway for outbound traffic',
),
default: pkg.outboundGateway ?? SYSTEM_KEY,
values: options,
}),
})
this.formDialog.open(FormComponent, {
label: 'Set Outbound Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setServiceOutbound({
package: pkg.manifest.id,
gateway: input.gateway === SYSTEM_KEY ? null : input.gateway,
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
protected readonly isInactive = computed(
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
(pkg = this.package()) => !pkg || INACTIVE_STATUSES.includes(pkg.status),
)
}

View File

@@ -17,10 +17,7 @@ import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import {
getPublicDomains,
InterfaceService,
} from '../../../components/interfaces/interface.service'
import { InterfaceService } from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
@@ -100,6 +97,7 @@ export default class ServiceInterfaceRoute {
readonly interfaceId = input('')
readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
readonly isRunning = computed(() => {
const pkg = this.pkg()
@@ -125,23 +123,16 @@ export default class ServiceInterfaceRoute {
}
const binding = host.bindings[port]
const gateways = this.gatewayService.gateways() || []
return {
...iFace,
addresses: this.interfaceService.getAddresses(iFace, host, gateways),
gateways:
gateways.map(g => ({
enabled:
(g.public
? binding?.net.publicEnabled.includes(g.id)
: !binding?.net.privateDisabled.includes(g.id)) ?? false,
...g,
})) || [],
torDomains: host.onions,
publicDomains: getPublicDomains(host.publicDomains, gateways),
privateDomains: host.privateDomains,
gatewayGroups: this.interfaceService.getGatewayGroups(
iFace,
host,
gateways,
),
pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()),
addSsl: !!binding?.options.addSsl,
}
})

View File

@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { getPkgId } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types'
import { FollowServerLogsReq } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
@@ -24,9 +25,9 @@ export default class ServiceLogsRoute {
readonly id = getPkgId()
readonly follow = async (params: RR.FollowServerLogsReq) =>
readonly follow = async (params: FollowServerLogsReq) =>
this.api.followPackageLogs({ id: this.id, ...params })
readonly fetch = async (params: RR.GetServerLogsReq) =>
readonly fetch = async (params: T.LogsParams) =>
this.api.getPackageLogs({ id: this.id, ...params })
}

View File

@@ -45,37 +45,19 @@ import { getManifest } from 'src/app/utils/get-package-data'
</header>
<nav>
@for (item of nav; track $index) {
@if (item.title === 'Documentation') {
<a
tuiCell
tuiAppearance="action-grayscale"
[href]="manifest()?.docsUrl"
target="_blank"
noreferrer
>
<tui-icon [icon]="item.icon" />
<span tuiTitle>
<span>
{{ item.title | i18n }}
</span>
</span>
<tui-icon icon="@tui.external-link" [style.font-size.rem]="1" />
</a>
} @else {
<a
tuiCell
tuiAppearance="action-grayscale"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="item.title === 'dashboard' ? './' : item.title"
>
<tui-icon [icon]="item.icon" />
<span tuiTitle>{{ item.title | i18n }}</span>
@if (item.title === 'dashboard') {
<a routerLink="interface" routerLinkActive="active"></a>
}
</a>
}
<a
tuiCell
tuiAppearance="action-grayscale"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="item.title === 'dashboard' ? './' : item.title"
>
<tui-icon [icon]="item.icon" />
<span tuiTitle>{{ item.title | i18n }}</span>
@if (item.title === 'dashboard') {
<a routerLink="interface" routerLinkActive="active"></a>
}
</a>
}
</nav>
</aside>
@@ -118,9 +100,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
.title::before {
content: '';
position: absolute;
inset: 0;
inset: -0.5rem -1rem 0;
background: var(--background);
background-size: 1px;
filter: blur(0.5rem);
mask: linear-gradient(to bottom, black, transparent);
opacity: 0.2;
}
@@ -209,7 +192,6 @@ export class ServiceOutletComponent {
{ title: 'actions', icon: '@tui.file-terminal' },
{ title: 'logs', icon: '@tui.logs' },
{ title: 'about', icon: '@tui.info' },
{ title: 'Documentation', icon: '@tui.book-open-text' },
]
protected readonly service = toSignal(

View File

@@ -13,7 +13,6 @@ import {
TuiFiles,
tuiInputFilesOptionsProvider,
} from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component'
@@ -55,11 +54,6 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
<div>
<tui-avatar appearance="secondary" src="@tui.upload" />
<p>{{ 'Upload .s9pk package file' | i18n }}</p>
@if (isTor) {
<p class="g-warning">
{{ 'Warning: package upload will be slow over Tor.' | i18n }}
</p>
}
<button tuiButton>{{ 'Select' | i18n }}</button>
</div>
}
@@ -92,8 +86,6 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
],
})
export default class SideloadComponent {
readonly isTor = inject(ConfigService).accessType === 'tor'
file: File | null = null
readonly package = signal<MarketplacePkgSideload | null>(null)
readonly error = signal<i18nKey | null>(null)

View File

@@ -21,7 +21,7 @@ import { AuthoritiesTableComponent } from './table.component'
tuiIconButton
size="xs"
docsLink
path="/user-manual/authorities.html"
path="/start-os/user-manual/trust-ca.html"
appearance="icon"
iconStart="@tui.external-link"
>

View File

@@ -7,7 +7,7 @@ import { AuthorityService } from './authority.service'
selector: 'authorities-table',
template: `
<table [appTable]="['Provider', 'URL', 'Contact', null]">
<tr [authority]="{ name: 'Local Root CA' }"></tr>
<tr [authority]="{ name: 'Root CA' }"></tr>
@for (authority of authorityService.authorities(); track $index) {
<tr [authority]="authority"></tr>
}

View File

@@ -1,8 +1,7 @@
import { inject, Injectable, signal } from '@angular/core'
import { ErrorService, getErrorMessage } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import { T, Version } from '@start9labs/start-sdk'
import {
BackupTarget,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
@@ -61,13 +60,13 @@ export class BackupService {
}
}
hasAnyBackup({ startOs }: BackupTarget): boolean {
hasAnyBackup({ startOs }: T.BackupTarget): boolean {
return Object.values(startOs).some(
s => Version.parse(s.version).compare(Version.parse('0.3.6')) !== 'less',
)
}
hasThisBackup({ startOs }: BackupTarget, id: string): boolean {
hasThisBackup({ startOs }: T.BackupTarget, id: string): boolean {
const item = startOs[id]
return (

View File

@@ -1,9 +1,8 @@
import { T } from '@start9labs/start-sdk'
import { TuiDialogContext } from '@taiga-ui/core'
import {
BackupInfo,
CifsBackupTarget,
DiskBackupTarget,
PackageBackupInfo,
} from 'src/app/services/api/api.types'
import { MappedBackupTarget } from './backup.service'
@@ -12,7 +11,7 @@ export type BackupContext = TuiDialogContext<
MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
>
export interface RecoverOption extends PackageBackupInfo {
export interface RecoverOption extends T.PackageBackupInfo {
id: string
checked: boolean
installed: boolean
@@ -22,6 +21,6 @@ export interface RecoverOption extends PackageBackupInfo {
export interface RecoverData {
targetId: string
serverId: string
backupInfo: BackupInfo
backupInfo: T.BackupInfo
password: string
}

View File

@@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-create.html"
path="/start-os/user-manual/backup-create.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[textContent]="'View instructions' | i18n"
@@ -79,7 +79,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-restore.html"
path="/start-os/user-manual/backup-restore.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[textContent]="'View instructions' | i18n"
@@ -121,7 +121,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-create.html"
path="/start-os/user-manual/backup-create.html"
fragment="#network-folder"
appearance="action-grayscale"
iconEnd="@tui.external-link"

View File

@@ -11,14 +11,14 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { CifsBackupTarget, RR } from 'src/app/services/api/api.types'
import { CifsBackupTarget } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@@ -211,7 +211,7 @@ export class BackupNetworkComponent {
buttons: [
{
text: this.i18n.transform('Connect'),
handler: (value: RR.AddBackupTargetReq) => this.addTarget(value),
handler: (value: T.CifsAddParams) => this.addTarget(value),
},
],
},
@@ -226,7 +226,7 @@ export class BackupNetworkComponent {
buttons: [
{
text: this.i18n.transform('Connect'),
handler: async (value: RR.AddBackupTargetReq) => {
handler: async (value: T.CifsAddParams) => {
const loader = this.loader
.open('Testing connectivity to shared folder')
.subscribe()
@@ -272,7 +272,7 @@ export class BackupNetworkComponent {
})
}
private async addTarget(v: RR.AddBackupTargetReq): Promise<boolean> {
private async addTarget(v: T.CifsAddParams): Promise<boolean> {
const loader = this.loader
.open('Testing connectivity to shared folder')
.subscribe()

View File

@@ -148,8 +148,8 @@ export class BackupsRecoverComponent {
async restore(options: RecoverOption[]): Promise<void> {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const { targetId, serverId, password } = this.context.data
const params = { ids, targetId, serverId, password }
const { targetId, password } = this.context.data
const params = { ids, targetId, password }
const loader = this.loader.open('Initializing').subscribe()
try {

View File

@@ -47,7 +47,7 @@ const ipv6 =
tuiIconButton
size="xs"
docsLink
path="/user-manual/dns.html"
path="/start-os/user-manual/dns.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -184,7 +184,9 @@ export default class SystemDnsComponent {
if (
Object.values(pkgs).some(p =>
Object.values(p.hosts).some(h => h?.privateDomains.length),
Object.values(p.hosts).some(
h => Object.keys(h?.privateDomains || {}).length,
),
)
) {
Object.values(gateways)

View File

@@ -1,5 +1,10 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
@@ -10,11 +15,11 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { inputSpec, IST } from '@start9labs/start-sdk'
import { inputSpec } from '@start9labs/start-sdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { switchMap, tap } from 'rxjs'
import { Subscription, switchMap, tap } from 'rxjs'
import { FormGroupComponent } from 'src/app/routes/portal/components/form/containers/group.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
@@ -22,6 +27,32 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
const PROVIDER_HINTS: Record<string, string> = {
gmail:
'Requires an App Password. Enable 2FA in your Google account, then generate an App Password.',
ses: 'Use SMTP credentials (not IAM credentials). Update the host to match your SES region.',
sendgrid:
"Username is 'apikey' (literal). Password is your SendGrid API key.",
mailgun: 'Use SMTP credentials from your Mailgun domain settings.',
protonmail:
'Requires a Proton for Business account. Use your Proton email as username.',
}
function detectProviderKey(host: string | undefined): string {
if (!host) return 'other'
const providers: Record<string, string> = {
'smtp.gmail.com': 'gmail',
'smtp.sendgrid.net': 'sendgrid',
'smtp.mailgun.org': 'mailgun',
'smtp.protonmail.ch': 'protonmail',
}
for (const [h, key] of Object.entries(providers)) {
if (host === h) return key
}
if (host.endsWith('.amazonaws.com')) return 'ses'
return 'other'
}
@Component({
template: `
<ng-container *title>
@@ -40,7 +71,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
tuiIconButton
size="xs"
docsLink
path="/user-manual/smtp.html"
path="/start-os/user-manual/smtp.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -52,6 +83,9 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@if (spec | async; as resolved) {
<form-group [spec]="resolved" />
}
@if (providerHint()) {
<p class="provider-hint">{{ providerHint() }}</p>
}
<footer>
@if (isSaved) {
<button
@@ -116,6 +150,12 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
footer {
justify-content: flex-end;
}
.provider-hint {
margin: 0.5rem 0 0;
font-size: 0.85rem;
opacity: 0.7;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -142,27 +182,45 @@ export default class SystemEmailComponent {
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly providerHint = signal('')
private providerSub: Subscription | null = null
testAddress = ''
isSaved = false
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
inputSpec.constants.customSmtp,
)
readonly spec = configBuilderToSpec(inputSpec.constants.systemSmtpSpec)
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
tap(value => (this.isSaved = !!value)),
switchMap(async value =>
this.formService.createForm(await this.spec, value),
),
tap(value => {
this.isSaved = !!value
}),
switchMap(async value => {
const spec = await this.spec
const formData = value
? { provider: { selection: detectProviderKey(value.host), value } }
: undefined
const form = this.formService.createForm(spec, formData)
// Watch provider selection for hints
this.providerSub?.unsubscribe()
const selectionCtrl = form.get('provider.selection')
if (selectionCtrl) {
this.providerHint.set(PROVIDER_HINTS[selectionCtrl.value] || '')
this.providerSub = selectionCtrl.valueChanges.subscribe(key => {
this.providerHint.set(PROVIDER_HINTS[key] || '')
})
}
return form
}),
)
async save(
value: typeof inputSpec.constants.customSmtp._TYPE | null,
): Promise<void> {
async save(formValue: Record<string, any> | null): Promise<void> {
const loader = this.loader.open('Saving').subscribe()
try {
if (value) {
await this.api.setSmtp(value)
if (formValue) {
await this.api.setSmtp(formValue['provider'].value)
this.isSaved = true
} else {
await this.api.clearSmtp({})
@@ -175,13 +233,18 @@ export default class SystemEmailComponent {
}
}
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
async sendTestEmail(formValue: Record<string, any>) {
const smtpValue = formValue['provider'].value
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({ to: this.testAddress, ...value })
await this.api.testSmtp({
...smtpValue,
password: smtpValue.password || '',
to: this.testAddress,
})
this.dialog
.openAlert(success, { label: 'Success', size: 's' })
.subscribe()

View File

@@ -32,7 +32,7 @@ import { ISB } from '@start9labs/start-sdk'
tuiIconButton
size="xs"
docsLink
path="/user-manual/gateways.html"
path="/start-os/user-manual/gateways.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -51,11 +51,6 @@ import { ISB } from '@start9labs/start-sdk'
<gateways-table />
</section>
`,
styles: `
:host {
max-width: 64rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
@@ -86,7 +81,7 @@ export default class GatewaysComponent {
placeholder: 'StartTunnel 1',
}),
config: ISB.Value.union({
name: this.i18n.transform('StartTunnel Config File'),
name: this.i18n.transform('WireGuard Config File'),
default: 'paste',
variants: ISB.Variants.of({
paste: {
@@ -96,8 +91,8 @@ export default class GatewaysComponent {
name: this.i18n.transform('File Contents'),
default: null,
required: true,
minRows: 16,
maxRows: 16,
minRows: 8,
maxRows: 8,
}),
}),
},
@@ -113,10 +108,17 @@ export default class GatewaysComponent {
},
}),
}),
setAsDefaultOutbound: ISB.Value.toggle({
name: this.i18n.transform('Set as default outbound'),
description: this.i18n.transform(
'Route all outbound traffic through this gateway',
),
default: false,
}),
})
this.formDialog.open(FormComponent, {
label: 'Add StartTunnel Gateway',
label: 'Add Wireguard Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
@@ -132,7 +134,8 @@ export default class GatewaysComponent {
input.config.selection === 'paste'
? input.config.value.file
: await (input.config.value.file as any as File).text(),
public: false,
type: null, // @TODO Aiden why is attr here?
setAsDefaultOutbound: input.setAsDefaultOutbound,
})
return true
} catch (e: any) {

View File

@@ -15,6 +15,7 @@ import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiOptGroup,
TuiTextfield,
} from '@taiga-ui/core'
@@ -24,28 +25,44 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { TuiBadge } from '@taiga-ui/kit'
import { PORT_FORWARDS_MODAL } from './port-forwards.component'
@Component({
selector: 'tr[gateway]',
template: `
@if (gateway(); as gateway) {
<td class="name">
<td>
@switch (gateway.ipInfo.deviceType) {
@case ('ethernet') {
<tui-icon icon="@tui.ethernet-port" />
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
}
}
{{ gateway.name }}
</td>
<td class="type">
@if (gateway.ipInfo.deviceType; as type) {
{{ type }} ({{
gateway.public ? ('public' | i18n) : ('private' | i18n)
}})
} @else {
-
@if (gateway.isDefaultOutbound) {
<tui-badge appearance="primary-success">
{{ 'default outbound' | i18n }}
</tui-badge>
}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
<td>
@if (gateway.type === 'outbound-only') {
{{ 'Outbound Only' | i18n }}
} @else {
{{ 'Inbound/Outbound' | i18n }}
}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td
class="wan"
[style.color]="
gateway.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined
gateway.ipInfo.wanIp ? undefined : 'var(--tui-text-warning)'
"
>
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
@@ -63,19 +80,27 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
<button tuiOption new (click)="rename()">
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
@if (gateway.type !== 'outbound-only') {
<tui-opt-group>
<button tuiOption new (click)="viewPortForwards()">
{{ 'View port forwards' | i18n }}
</button>
</tui-opt-group>
}
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button tuiOption new (click)="setDefaultOutbound()">
{{ 'Set as default outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>
<button tuiOption new class="g-negative" (click)="remove()">
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
@@ -86,45 +111,55 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
}
`,
styles: `
tui-icon {
font-size: 1.3rem;
margin-right: 0.7rem;
}
tui-badge {
margin-left: 1rem;
}
td:last-child {
grid-area: 1 / 3 / 5;
align-self: center;
text-align: right;
}
.name {
width: 14rem;
}
.type {
width: 14rem;
}
:host-context(tui-root._mobile) {
grid-template-columns: min-content 1fr min-content;
.name {
grid-column: span 2;
td {
width: auto !important;
align-content: center;
}
.type {
grid-column: span 2;
order: -1;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
}
.lan,
.wan {
grid-column: span 2;
td:nth-child(2) {
grid-area: 2 / 1 / 2 / 3;
}
td:nth-child(3),
td:nth-child(4) {
grid-area: auto / 1 / auto / 3;
&::before {
content: 'LAN IP: ';
color: var(--tui-text-primary);
}
}
.wan::before {
td:nth-child(3)::before {
content: 'LAN IP: ';
}
td:nth-child(4)::before {
content: 'WAN IP: ';
}
td:last-child {
grid-area: 1 / 3 / 6;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -132,9 +167,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
TuiButton,
TuiDropdown,
TuiDataList,
TuiIcon,
TuiOptGroup,
TuiTextfield,
i18nPipe,
TuiBadge,
],
})
export class GatewaysItemComponent {
@@ -149,6 +186,17 @@ export class GatewaysItemComponent {
open = false
viewPortForwards() {
const { id, name } = this.gateway()
this.dialog
.openComponent(PORT_FORWARDS_MODAL, {
label: 'Port Forwards',
size: 'l',
data: { gatewayId: id, gatewayName: name },
})
.subscribe()
}
remove() {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
@@ -166,6 +214,18 @@ export class GatewaysItemComponent {
})
}
async setDefaultOutbound() {
const loader = this.loader.open().subscribe()
try {
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async rename() {
const { id, name } = this.gateway()
const renameSpec = ISB.InputSpec.of({

View File

@@ -0,0 +1,262 @@
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { TuiButtonLoading } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
export type PortForwardsModalData = {
gatewayId: string
gatewayName: string
}
type PortForwardRow = {
interfaces: string[]
externalPort: number
internalPort: number
}
function parseSocketAddr(s: string): { ip: string; port: number } {
const lastColon = s.lastIndexOf(':')
return {
ip: s.substring(0, lastColon),
port: Number(s.substring(lastColon + 1)),
}
}
@Component({
selector: 'port-forwards-modal',
template: `
<p>
{{ 'Port forwarding rules required on gateway' | i18n }}
"{{ context.data.gatewayName }}"
</p>
<table
[appTable]="[
'Interface(s)',
null,
'External Port',
'Internal Port',
null,
]"
>
@for (row of rows(); track row.externalPort; let i = $index) {
<tr>
<td class="interfaces">
@for (iface of row.interfaces; track iface) {
<div>{{ iface }}</div>
}
<port-check-warnings [result]="results()[i]" />
</td>
<td class="status">
<port-check-icon
[result]="results()[i]"
[loading]="!!loading()[i]"
/>
</td>
<td>{{ row.externalPort }}</td>
<td>{{ row.internalPort }}</td>
<td>
<button
tuiButton
size="s"
[loading]="!!loading()[i]"
(click)="testPort(i, row.externalPort)"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No port forwarding rules' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
p {
margin: 0 0 1rem 0;
}
.interfaces {
white-space: nowrap;
}
.status {
width: 3.2rem;
}
td:last-child {
text-align: end;
}
:host-context(tui-root._mobile) table {
thead {
display: table-header-group !important;
}
tr {
display: table-row !important;
box-shadow: none !important;
}
td,
th {
padding: 0.5rem 0.5rem !important;
font: var(--tui-font-text-s) !important;
color: var(--tui-text-primary) !important;
font-weight: normal !important;
}
th {
font-weight: bold !important;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
PlaceholderComponent,
PortCheckIconComponent,
PortCheckWarningsComponent,
TuiButtonLoading,
],
})
export class PortForwardsModalComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
readonly context =
injectContext<TuiDialogContext<void, PortForwardsModalData>>()
readonly loading = signal<Record<number, boolean>>({})
readonly results = signal<Record<number, T.CheckPortRes>>({})
private readonly portForwards$ = combineLatest([
this.patch.watch$('serverInfo', 'network', 'host', 'portForwards').pipe(
map(pfs =>
pfs.map(pf => ({
...pf,
interfaces: ['StartOS - UI'],
})),
),
),
this.patch.watch$('packageData').pipe(
map(pkgData => {
const rows: Array<{
src: string
dst: string
gateway: string
interfaces: string[]
}> = []
for (const [pkgId, pkg] of Object.entries(pkgData)) {
const title =
pkg.stateInfo.manifest?.title ??
pkg.stateInfo.installingInfo?.newManifest?.title ??
pkgId
for (const [hostId, host] of Object.entries(pkg.hosts)) {
// Find interface names pointing to this host
const ifaceNames: string[] = []
for (const iface of Object.values(pkg.serviceInterfaces)) {
if (iface.addressInfo.hostId === hostId) {
ifaceNames.push(`${title} - ${iface.name}`)
}
}
const label =
ifaceNames.length > 0 ? ifaceNames : [`${title} - ${hostId}`]
for (const pf of host.portForwards) {
rows.push({ ...pf, interfaces: label })
}
}
}
return rows
}),
),
]).pipe(
map(([osForwards, pkgForwards]) => {
const gatewayId = this.context.data.gatewayId
const all = [...osForwards, ...pkgForwards].filter(
pf => pf.gateway === gatewayId,
)
// Group by (externalPort, internalPort)
const grouped = new Map<string, PortForwardRow>()
for (const pf of all) {
const src = parseSocketAddr(pf.src)
const dst = parseSocketAddr(pf.dst)
const key = `${src.port}:${dst.port}`
const existing = grouped.get(key)
if (existing) {
for (const iface of pf.interfaces) {
if (!existing.interfaces.includes(iface)) {
existing.interfaces.push(iface)
}
}
} else {
grouped.set(key, {
interfaces: [...pf.interfaces],
externalPort: src.port,
internalPort: dst.port,
})
}
}
return [...grouped.values()].sort(
(a, b) => a.externalPort - b.externalPort,
)
}),
)
readonly rows = toSignal(this.portForwards$, { initialValue: [] })
async testPort(index: number, port: number) {
this.loading.update(l => ({ ...l, [index]: true }))
try {
const result = await this.api.checkPort({
gateway: this.context.data.gatewayId,
port,
})
this.results.update(r => ({ ...r, [index]: result }))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.update(l => ({ ...l, [index]: false }))
}
}
}
export const PORT_FORWARDS_MODAL = new PolymorpheusComponent(
PortForwardsModalComponent,
)

View File

@@ -13,7 +13,7 @@ import { GatewayService } from 'src/app/services/gateway.service'
<tr [gateway]="gateway"></tr>
} @empty {
<tr>
<td colspan="5">
<td colspan="6">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import {
DialogService,
@@ -24,14 +23,13 @@ import {
LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { WA_WINDOW } from '@ng-web-apis/common'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
tuiFadeIn,
TuiIcon,
tuiScaleIn,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
@@ -47,14 +45,14 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { SnekDirective } from './snek.directive'
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
import { SnakeDirective } from './snake.directive'
import { UPDATE } from './update.component'
import { SystemWipeComponent } from './wipe.component'
import { KeyboardSelectComponent } from './keyboard-select.component'
import { ServerNameDialog } from './server-name.dialog'
@Component({
template: `
@@ -66,17 +64,28 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</ng-container>
@if (server(); as server) {
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" />
<tui-icon icon="@tui.info" />
<span tuiTitle>
<strong>{{ 'Software Update' | i18n }}</strong>
<span tuiSubtitle [style.flex-wrap]="'wrap'">
{{ server.version }}
<strong>{{ 'About this server' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Version, Root CA, and more' | i18n }}
</span>
</span>
<button tuiButton (click)="about()">
{{ 'Details' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" (click)="count = count + 1" />
<span tuiTitle>
<strong>
{{ 'Software Update' | i18n }}
@if (os.showUpdate$ | async) {
<tui-badge-notification>
{{ 'Update available' | i18n }}
</tui-badge-notification>
}
</span>
</strong>
</span>
<button
tuiButton
@@ -97,14 +106,17 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<tui-icon icon="@tui.server" />
<span tuiTitle>
<strong>{{ 'Browser tab title' | i18n }}</strong>
<strong>{{ 'Server Name' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Customize the name appearing in your browser tab' | i18n }}
{{ server.name }}
</span>
<span tuiSubtitle>{{ server.hostname }}.local</span>
</span>
<button tuiButton (click)="onTitle()">{{ 'Change' | i18n }}</button>
<button tuiButton (click)="onName()">
{{ 'Change' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.languages" />
@@ -178,18 +190,6 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</button>
}
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.rotate-cw" (click)="count = count + 1" />
<span tuiTitle>
<strong>{{ 'Restart Tor' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Restart the Tor daemon on your server' | i18n }}
</span>
</span>
<button tuiButton appearance="glass" (click)="onTorRestart()">
{{ 'Restart' | i18n }}
</button>
</div>
@if (count > 4) {
<div tuiCell tuiAppearance="outline-grayscale" tuiAnimated>
<tui-icon icon="@tui.briefcase-medical" />
@@ -203,10 +203,10 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
</div>
}
<img
[snek]="score() || 0"
class="snek"
[snake]="score() || 0"
class="snake"
alt="Play Snake"
src="assets/img/icons/snek.png"
src="assets/img/icons/snake.png"
/>
}
`,
@@ -215,7 +215,7 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
max-inline-size: 40rem;
}
.snek {
.snake {
width: 1rem;
opacity: 0.2;
cursor: pointer;
@@ -270,29 +270,30 @@ import { KeyboardSelectComponent } from './keyboard-select.component'
TuiDataListWrapper,
TuiTextfield,
FormsModule,
SnekDirective,
SnakeDirective,
TuiBadge,
TuiBadgeNotification,
TuiAnimated,
],
})
export default class SystemGeneralComponent {
private readonly title = inject(Title)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly isTor = inject(ConfigService).accessType === 'tor'
private readonly dialog = inject(DialogService)
private readonly i18n = inject(i18nPipe)
private readonly injector = inject(INJECTOR)
private readonly win = inject(WA_WINDOW)
wipe = false
count = 0
about() {
this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe()
}
readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService)
readonly i18nService = inject(i18nService)
@@ -362,52 +363,80 @@ export default class SystemGeneralComponent {
}
}
onTitle() {
const sub = this.dialog
.openPrompt<string>({
label: 'Browser tab title',
data: {
label: 'Device Name',
message:
'This value will be displayed as the title of your browser tab.',
placeholder: 'StartOS' as i18nKey,
required: false,
buttonText: 'Save',
initialValue: this.name(),
},
})
.subscribe(async name => {
const loader = this.loader.open('Saving').subscribe()
const title = `${name || 'StartOS'}${this.i18n.transform('System')}`
onName() {
const server = this.server()
if (!server) return
try {
await this.api.setDbValue(['name'], name || null)
this.title.setTitle(title)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
sub.unsubscribe()
this.dialog
.openComponent<{ name: string; hostname: string } | null>(
new PolymorpheusComponent(ServerNameDialog, this.injector),
{
label: 'Server Name',
size: 's',
data: { initialName: server.name },
},
)
.pipe(
filter(
(result): result is { name: string; hostname: string } =>
result !== null,
),
)
.subscribe(result => {
if (this.win.location.hostname.endsWith('.local')) {
this.confirmNameChange(result)
} else {
this.saveName(result)
}
})
}
onTorRestart() {
this.wipe = false
private confirmNameChange(result: { name: string; hostname: string }) {
this.dialog
.openConfirm({
label: this.isTor ? 'Warning' : 'Confirm',
label: 'Warning',
data: {
content: new PolymorpheusComponent(
SystemWipeComponent,
this.injector,
),
yes: 'Restart',
content:
'You are currently connected via your .local address. Changing the hostname will require you to switch to the new .local address.',
yes: 'Save',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
.subscribe(() => this.saveName(result, true))
}
private async saveName(
{ name, hostname }: { name: string; hostname: string },
wasLocal = false,
) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setHostname({ name, hostname })
if (wasLocal) {
const { protocol, port } = this.win.location
const newUrl = `${protocol}//${hostname}.local${port ? ':' + port : ''}`
this.dialog
.openConfirm({
label: 'Hostname Changed',
data: {
content:
`${this.i18n.transform('Your server is now reachable at')} ${hostname}.local` as i18nKey,
yes: 'Open new address',
no: 'Dismiss',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.win.open(newUrl, '_blank'))
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async onRepair() {
@@ -532,19 +561,6 @@ export default class SystemGeneralComponent {
.subscribe(() => this.restart())
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open().subscribe()
try {
await this.api.resetTor({ wipeState, reason: 'User triggered' })
this.dialog.openAlert('Tor restart in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private update() {
this.dialogs
.open(UPDATE, {

View File

@@ -0,0 +1,63 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe, normalizeHostname } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
template: `
<tui-textfield>
<label tuiLabel>{{ 'Server Name' | i18n }}</label>
<input tuiTextfield [(ngModel)]="name" />
</tui-textfield>
@if (name.trim()) {
<p class="hostname-preview">{{ normalizeHostname(name) }}.local</p>
}
<footer>
<button tuiButton appearance="secondary" (click)="cancel()">
{{ 'Cancel' | i18n }}
</button>
<button tuiButton [disabled]="!name.trim()" (click)="confirm()">
{{ 'Save' | i18n }}
</button>
</footer>
`,
styles: `
.hostname-preview {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 0.25rem;
}
footer {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
`,
imports: [FormsModule, TuiButton, TuiTextfield, i18nPipe],
})
export class ServerNameDialog {
private readonly context =
injectContext<
TuiDialogContext<
{ name: string; hostname: string } | null,
{ initialName: string }
>
>()
name = this.context.data.initialName
readonly normalizeHostname = normalizeHostname
cancel() {
this.context.completeWith(null)
}
confirm() {
const name = this.name.trim()
this.context.completeWith({
name,
hostname: normalizeHostname(name),
})
}
}

View File

@@ -0,0 +1,504 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
HostListener,
inject,
signal,
viewChild,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
type GameState = 'ready' | 'playing' | 'dead'
interface Point {
x: number
y: number
}
interface Snake {
cells: Point[]
dx: number
dy: number
maxCells: number
}
type RGB = [number, number, number]
const HEAD_COLOR: RGB = [47, 223, 117] // #2fdf75
const TAIL_COLOR: RGB = [20, 90, 48] // #145a30
const GRID_W = 40
const GRID_H = 26
const SPEED = 45
const STARTING_LENGTH = 4
const SAAS_ICONS = [
'adobe',
'amazon',
'anthropic',
'apple',
'atlassian',
'box',
'cloudflare',
'datadog',
'discord',
'dropbox',
'github',
'gitlab',
'godaddy',
'google',
'hubspot',
'icloud',
'lastpass',
'meta',
'microsoft',
'mongodb',
'netflix',
'notion',
'onepassword',
'openai',
'paypal',
'salesforce',
'shopify',
'slack',
'spotify',
'squarespace',
'square',
'stripe',
'twilio',
'wix',
'zoom',
].map(name => `assets/img/icons/saas/${name}.svg`)
function lerpColor(from: RGB, to: RGB, t: number): string {
const r = Math.round(from[0] + (to[0] - from[0]) * t)
const g = Math.round(from[1] + (to[1] - from[1]) * t)
const b = Math.round(from[2] + (to[2] - from[2]) * t)
return `rgb(${r},${g},${b})`
}
@Component({
template: `
<div class="game-container">
<canvas #game></canvas>
@if (state() === 'ready') {
<div class="overlay">
<strong>{{ 'Press any key or tap to start' | i18n }}</strong>
<span class="arrows">← ↑ ↓ →</span>
</div>
}
@if (state() === 'dead') {
<div class="overlay">
<strong class="game-over">{{ 'Game Over' | i18n }}</strong>
<span>{{ 'Score' | i18n }}: {{ score }}</span>
<span class="hint">
{{ 'Press any key or tap to play again' | i18n }}
</span>
</div>
}
</div>
<footer>
<strong>{{ 'Score' | i18n }}: {{ score }}</strong>
<span>{{ 'High score' | i18n }}: {{ highScore }}</span>
<button tuiButton (click)="dismiss()">
{{ 'Save and quit' | i18n }}
</button>
</footer>
`,
styles: `
:host {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-container {
position: relative;
background: #111;
border-radius: 0.5rem;
display: flex;
justify-content: center;
}
canvas {
display: block;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: rgba(0, 0, 0, 0.7);
border-radius: 0.5rem;
font-size: 1.125rem;
color: #fff;
}
.game-over {
font-size: 1.5rem;
}
.arrows {
font-size: 1.5rem;
letter-spacing: 0.5rem;
opacity: 0.5;
}
.hint {
opacity: 0.6;
font-size: 0.875rem;
}
footer {
display: flex;
align-items: center;
justify-content: space-between;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe],
})
export class SnakeComponent {
private readonly destroyRef = inject(DestroyRef)
private readonly dialog = injectContext<TuiDialogContext<number, number>>()
private readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('game')
readonly state = signal<GameState>('ready')
highScore: number = this.dialog.data
score = 0
private grid = NaN
private canvasW = 0
private canvasH = 0
private ctx!: CanvasRenderingContext2D
private images: HTMLImageElement[] = []
private currentImage: HTMLImageElement | null = null
private animationId = 0
private lastTime = 0
private dead = false
private snake!: Snake
private food: Point = { x: NaN, y: NaN }
private moveQueue: string[] = []
constructor() {
for (const src of SAAS_ICONS) {
const img = new Image()
img.src = src
this.images.push(img)
}
afterNextRender(() => {
this.initCanvas()
this.snake = this.createSnake()
this.spawnFood()
this.drawFrame()
this.animationId = requestAnimationFrame(t => this.loop(t))
})
this.destroyRef.onDestroy(() => {
cancelAnimationFrame(this.animationId)
})
}
dismiss() {
this.dialog.completeWith(this.highScore)
}
@HostListener('document:keydown', ['$event'])
onKeydown(e: KeyboardEvent) {
if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
e.preventDefault()
}
const current = this.state()
if (current === 'ready') {
this.state.set('playing')
this.lastTime = 0
// Queue directional input so first keypress sets direction
if (e.key.startsWith('Arrow')) {
this.moveQueue.push(e.key)
}
return
}
if (current === 'dead' && !this.dead) {
this.restart()
return
}
if (current === 'playing') {
this.moveQueue.push(e.key)
}
}
@HostListener('touchstart', ['$event'])
onTouchStart(e: TouchEvent) {
const current = this.state()
if (current === 'ready') {
this.state.set('playing')
this.lastTime = 0
return
}
if (current === 'dead' && !this.dead) {
this.restart()
return
}
this.touchStart = {
x: e.touches[0]?.clientX ?? 0,
y: e.touches[0]?.clientY ?? 0,
}
}
@HostListener('touchmove', ['$event'])
onTouchMove(e: TouchEvent) {
if (!this.touchStart || this.state() !== 'playing') return
const xUp = e.touches[0]?.clientX ?? 0
const yUp = e.touches[0]?.clientY ?? 0
const xDiff = this.touchStart.x - xUp
const yDiff = this.touchStart.y - yUp
if (Math.abs(xDiff) > Math.abs(yDiff)) {
this.moveQueue.push(xDiff > 0 ? 'ArrowLeft' : 'ArrowRight')
} else {
this.moveQueue.push(yDiff > 0 ? 'ArrowUp' : 'ArrowDown')
}
this.touchStart = null
}
@HostListener('window:resize')
onResize() {
this.initCanvas()
this.drawFrame()
}
private touchStart: Point | null = null
private initCanvas() {
const canvas = this.canvasRef()?.nativeElement
if (!canvas) return
this.ctx = canvas.getContext('2d')!
const container = canvas.parentElement!
const dpr = window.devicePixelRatio || 1
// Size grid based on available width, cap so canvas height stays reasonable
const maxHeight = window.innerHeight * 0.55
this.grid = Math.min(
Math.floor(container.clientWidth / GRID_W),
Math.floor(maxHeight / GRID_H),
)
this.canvasW = this.grid * GRID_W
this.canvasH = this.grid * GRID_H
canvas.width = this.canvasW * dpr
canvas.height = this.canvasH * dpr
canvas.style.width = `${this.canvasW}px`
canvas.style.height = `${this.canvasH}px`
this.ctx.scale(dpr, dpr)
}
private createSnake(): Snake {
return {
cells: [],
dx: this.grid,
dy: 0,
maxCells: STARTING_LENGTH,
}
}
private getStartX(): number {
return this.grid * (Math.floor(GRID_W / 2) - STARTING_LENGTH)
}
private getStartY(): number {
return this.grid * Math.floor(GRID_H / 2)
}
private spawnFood() {
this.food = {
x: this.randomInt(0, GRID_W) * this.grid,
y: this.randomInt(0, GRID_H) * this.grid,
}
const img = this.images[this.randomInt(0, this.images.length)]!
this.currentImage = img.complete && img.naturalWidth ? img : null
if (!this.currentImage) {
img.onload = () => {
this.currentImage = img
this.drawFrame()
}
}
}
private restart() {
this.score = 0
this.snake = this.createSnake()
this.moveQueue = []
this.spawnFood()
this.lastTime = 0
this.state.set('playing')
}
private loop(timestamp: number) {
this.animationId = requestAnimationFrame(t => this.loop(t))
if (this.state() !== 'playing') return
if (this.lastTime && timestamp - this.lastTime < SPEED) return
this.lastTime = timestamp
this.update()
this.drawFrame()
}
private update() {
// Process next queued move
if (this.moveQueue.length) {
const move = this.moveQueue.shift()!
if (move === 'ArrowLeft' && this.snake.dx === 0) {
this.snake.dx = -this.grid
this.snake.dy = 0
} else if (move === 'ArrowUp' && this.snake.dy === 0) {
this.snake.dy = -this.grid
this.snake.dx = 0
} else if (move === 'ArrowRight' && this.snake.dx === 0) {
this.snake.dx = this.grid
this.snake.dy = 0
} else if (move === 'ArrowDown' && this.snake.dy === 0) {
this.snake.dy = this.grid
this.snake.dx = 0
}
}
// Determine new head position
const prev = this.snake.cells[0]
const newHead: Point = prev
? { x: prev.x + this.snake.dx, y: prev.y + this.snake.dy }
: {
x: this.getStartX() + this.snake.dx,
y: this.getStartY() + this.snake.dy,
}
this.snake.cells.unshift(newHead)
// Trim tail
while (this.snake.cells.length > this.snake.maxCells) {
this.snake.cells.pop()
}
// Wall collision
if (
newHead.x < 0 ||
newHead.y < 0 ||
newHead.x >= this.canvasW ||
newHead.y >= this.canvasH
) {
this.onDeath()
return
}
// Self collision
for (let i = 1; i < this.snake.cells.length; i++) {
const cell = this.snake.cells[i]
if (cell && newHead.x === cell.x && newHead.y === cell.y) {
this.onDeath()
return
}
}
// Eat food
if (newHead.x === this.food.x && newHead.y === this.food.y) {
this.score++
this.highScore = Math.max(this.score, this.highScore)
this.snake.maxCells++
this.spawnFood()
}
}
private onDeath() {
this.dead = true
this.state.set('dead')
// Brief delay before accepting restart input
setTimeout(() => {
this.dead = false
}, 300)
}
private drawFrame() {
if (!this.ctx) return
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH)
this.drawFood()
this.drawSnake()
}
private drawFood() {
if (!this.currentImage) return
this.ctx.drawImage(
this.currentImage,
this.food.x,
this.food.y,
this.grid,
this.grid,
)
}
private drawSnake() {
const { cells } = this.snake
if (cells.length === 0) {
// Draw initial position in bottom-left corner (out of overlay text)
const x = STARTING_LENGTH * this.grid
const y = this.canvasH ? this.canvasH - this.grid * 2 : this.getStartY()
for (let i = 0; i < STARTING_LENGTH; i++) {
const t = STARTING_LENGTH > 1 ? i / (STARTING_LENGTH - 1) : 0
this.ctx.fillStyle = lerpColor(HEAD_COLOR, TAIL_COLOR, t)
const r = i === 0 ? this.grid * 0.35 : this.grid * 0.2
const size = this.grid - 1
this.ctx.beginPath()
this.ctx.roundRect(x - i * this.grid + 0.5, y + 0.5, size, size, r)
this.ctx.fill()
}
return
}
// Draw tail-first so head renders on top
for (let i = cells.length - 1; i >= 0; i--) {
const cell = cells[i]
if (!cell) continue
const t = cells.length > 1 ? i / (cells.length - 1) : 0
this.ctx.fillStyle = lerpColor(HEAD_COLOR, TAIL_COLOR, t)
const r = i === 0 ? this.grid * 0.35 : this.grid * 0.2
const size = this.grid - 1
this.ctx.beginPath()
this.ctx.roundRect(cell.x + 0.5, cell.y + 0.5, size, size, r)
this.ctx.fill()
}
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min)) + min
}
}

View File

@@ -8,30 +8,31 @@ import {
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { SnekComponent } from './snek.component'
import { SnakeComponent } from './snake.component'
@Directive({
selector: 'img[snek]',
selector: 'img[snake]',
})
export class SnekDirective {
export class SnakeDirective {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialog = inject(DialogService)
@Input()
snek = 0
snake = 0
@HostListener('click')
async onClick() {
this.dialog
.openComponent<number>(new PolymorpheusComponent(SnekComponent), {
.openComponent<number>(new PolymorpheusComponent(SnakeComponent), {
label: 'Snake!' as i18nKey,
size: 'l',
closeable: false,
dismissible: false,
data: this.snek,
data: this.snake,
})
.pipe(filter(score => score > this.snek))
.pipe(filter(score => score > this.snake))
.subscribe(async score => {
const loader = this.loader.open('Saving high score').subscribe()

View File

@@ -1,295 +0,0 @@
import {
AfterViewInit,
Component,
HostListener,
inject,
OnDestroy,
DOCUMENT,
} from '@angular/core'
import { i18nPipe, pauseFor } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus'
@Component({
template: `
<div class="canvas-center">
<canvas id="game"></canvas>
</div>
<footer class="footer">
<strong>{{ 'Score' | i18n }}: {{ score }}</strong>
<span>{{ 'High score' | i18n }}: {{ highScore }}</span>
<button tuiButton (click)="dismiss()">
{{ 'Save and quit' | i18n }}
</button>
</footer>
`,
styles: `
.canvas-center {
min-height: 50vh;
padding-top: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 32px;
}
`,
imports: [TuiButton, i18nPipe],
})
export class SnekComponent implements AfterViewInit, OnDestroy {
private readonly document = inject(DOCUMENT)
private readonly dialog = injectContext<TuiDialogContext<number, number>>()
highScore: number = this.dialog.data
score = 0
private readonly speed = 45
private readonly width = 40
private readonly height = 26
private grid = NaN
private readonly startingLength = 4
private xDown?: number
private yDown?: number
private canvas!: HTMLCanvasElement
private image!: HTMLImageElement
private context!: CanvasRenderingContext2D
private snake: any
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
private moveQueue: String[] = []
private destroyed = false
dismiss() {
this.dialog.completeWith(this.highScore)
}
@HostListener('document:keydown', ['$event'])
keyEvent(e: KeyboardEvent) {
this.moveQueue.push(e.key)
}
@HostListener('touchstart', ['$event'])
touchStart(e: TouchEvent) {
this.handleTouchStart(e)
}
@HostListener('touchmove', ['$event'])
touchMove(e: TouchEvent) {
this.handleTouchMove(e)
}
@HostListener('window:resize')
sizeChange() {
this.init()
}
ngOnDestroy() {
this.destroyed = true
}
ngAfterViewInit() {
this.init()
this.image = new Image()
this.image.onload = () => {
requestAnimationFrame(async () => await this.loop())
}
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
}
init() {
this.canvas = this.document.querySelector('canvas#game')!
this.canvas.style.border = '1px solid #e0e0e0'
this.context = this.canvas.getContext('2d')!
const container = this.document.querySelector('.canvas-center')!
this.grid = Math.min(
Math.floor(container.clientWidth / this.width),
Math.floor(container.clientHeight / this.height),
)
this.snake = {
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
y: this.grid * Math.floor(this.height / 2),
// snake velocity. moves one grid length every frame in either the x or y direction
dx: this.grid,
dy: 0,
// keep track of all grids the snake body occupies
cells: [],
// length of the snake. grows when eating an bitcoin
maxCells: this.startingLength,
}
this.bitcoin = {
x: this.getRandomInt(0, this.width) * this.grid,
y: this.getRandomInt(0, this.height) * this.grid,
}
this.canvas.width = this.grid * this.width
this.canvas.height = this.grid * this.height
this.context.imageSmoothingEnabled = false
}
getTouches(evt: TouchEvent) {
return evt.touches
}
handleTouchStart(evt: TouchEvent) {
const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch?.clientX
this.yDown = firstTouch?.clientY
}
handleTouchMove(evt: TouchEvent) {
if (!this.xDown || !this.yDown) {
return
}
var xUp = evt.touches[0]?.clientX || 0
var yUp = evt.touches[0]?.clientY || 0
var xDiff = this.xDown - xUp
var yDiff = this.yDown - yUp
if (Math.abs(xDiff) > Math.abs(yDiff)) {
/*most significant*/
if (xDiff > 0) {
this.moveQueue.push('ArrowLeft')
} else {
this.moveQueue.push('ArrowRight')
}
} else {
if (yDiff > 0) {
this.moveQueue.push('ArrowUp')
} else {
this.moveQueue.push('ArrowDown')
}
}
/* reset values */
this.xDown = undefined
this.yDown = undefined
}
// game loop
async loop() {
if (this.destroyed) return
await pauseFor(this.speed)
requestAnimationFrame(async () => await this.loop())
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
// move snake by its velocity
this.snake.x += this.snake.dx
this.snake.y += this.snake.dy
if (this.moveQueue.length) {
const move = this.moveQueue.shift()
// left arrow key
if (move === 'ArrowLeft' && this.snake.dx === 0) {
this.snake.dx = -this.grid
this.snake.dy = 0
}
// up arrow key
else if (move === 'ArrowUp' && this.snake.dy === 0) {
this.snake.dy = -this.grid
this.snake.dx = 0
}
// right arrow key
else if (move === 'ArrowRight' && this.snake.dx === 0) {
this.snake.dx = this.grid
this.snake.dy = 0
}
// down arrow key
else if (move === 'ArrowDown' && this.snake.dy === 0) {
this.snake.dy = this.grid
this.snake.dx = 0
}
}
// edge death
if (
this.snake.x < 0 ||
this.snake.y < 0 ||
this.snake.x >= this.canvas.width ||
this.snake.y >= this.canvas.height
) {
this.death()
}
// keep track of where snake has been. front of the array is always the head
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
// remove cells as we move away from them
if (this.snake.cells.length > this.snake.maxCells) {
this.snake.cells.pop()
}
// draw bitcoin
this.context.fillStyle = '#ff4961'
this.context.drawImage(
this.image,
this.bitcoin.x - 1,
this.bitcoin.y - 1,
this.grid + 2,
this.grid + 2,
)
// draw snake one cell at a time
this.context.fillStyle = '#2fdf75'
const firstCell = this.snake.cells[0]
for (let index = 0; index < this.snake.cells.length; index++) {
const cell = this.snake.cells[index]
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
// snake ate bitcoin
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
this.score++
this.highScore = Math.max(this.score, this.highScore)
this.snake.maxCells++
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
}
if (index > 0) {
// check collision with all cells after this one (modified bubble sort)
// snake occupies same space as a body part. reset game
if (
firstCell.x === this.snake.cells[index].x &&
firstCell.y === this.snake.cells[index].y
) {
this.death()
}
}
}
}
death() {
this.snake.x =
this.grid * (Math.floor(this.width / 2) - this.startingLength)
this.snake.y = this.grid * Math.floor(this.height / 2)
this.snake.cells = []
this.snake.maxCells = this.startingLength
this.snake.dx = this.grid
this.snake.dy = 0
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
this.score = 0
}
getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min)) + min
}
}

View File

@@ -1,36 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiLabel } from '@taiga-ui/core'
import { TuiCheckbox } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import SystemGeneralComponent from './general.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
template: `
@if (isTor) {
<p>
{{
'You are currently connected over Tor. If you restart the Tor daemon, you will lose connectivity until it comes back online.'
| i18n
}}
</p>
}
<p>
{{
'Optionally wipe state to forcibly acquire new guard nodes. It is recommended to try without wiping state first.'
| i18n
}}
</p>
<label tuiLabel>
<input type="checkbox" tuiCheckbox [(ngModel)]="component.wipe" />
{{ 'Wipe state' | i18n }}
</label>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiLabel, FormsModule, TuiCheckbox, i18nPipe],
})
export class SystemWipeComponent {
readonly isTor = inject(ConfigService).accessType === 'tor'
readonly component = inject(SystemGeneralComponent)
}

View File

@@ -7,9 +7,9 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { from, map, merge, Observable, Subject } from 'rxjs'
import { Session } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SessionsTableComponent } from './table.component'
@@ -72,7 +72,7 @@ export default class SystemSessionsComponent {
readonly current$ = this.sessions$.pipe(
map(s => {
const current = s.sessions[s.current]
const current = s.current ? s.sessions[s.current] : undefined
return current ? [current] : []
}),
@@ -115,6 +115,6 @@ export default class SystemSessionsComponent {
}
}
interface SessionWithId extends Session {
interface SessionWithId extends T.Session {
id: string
}

View File

@@ -11,7 +11,7 @@ import { FormsModule } from '@angular/forms'
import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { Session } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
import { PlatformInfoPipe } from './platform-info.pipe'
import { i18nPipe } from '@start9labs/shared'
@@ -165,11 +165,11 @@ import { i18nPipe } from '@start9labs/shared'
i18nPipe,
],
})
export class SessionsTableComponent<T extends Session> implements OnChanges {
readonly sessions = input<readonly T[] | null>(null)
export class SessionsTableComponent<S extends T.Session> implements OnChanges {
readonly sessions = input<readonly S[] | null>(null)
readonly single = input(false)
readonly selected = signal<readonly T[]>([])
readonly selected = signal<readonly S[]>([])
readonly all = computed(
() =>
!!this.selected()?.length &&
@@ -180,7 +180,7 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
this.selected.set([])
}
onToggle(session: T) {
onToggle(session: S) {
if (this.selected().includes(session)) {
this.selected.update(selected => selected.filter(s => s !== session))
} else {

View File

@@ -13,11 +13,10 @@ import {
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiButton, TuiHint } from '@taiga-ui/core'
import { filter, from, merge, Subject } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { SSHKey } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { TitleDirective } from 'src/app/services/title.service'
@@ -40,7 +39,7 @@ import { SSHTableComponent } from './table.component'
tuiIconButton
size="xs"
docsLink
path="/user-manual/ssh.html"
path="/start-os/user-manual/ssh.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -101,13 +100,13 @@ export default class SystemSSHComponent {
private readonly i18n = inject(i18nPipe)
private readonly dialogs = inject(DialogService)
private readonly local$ = new Subject<readonly SSHKey[]>()
private readonly local$ = new Subject<readonly T.SshKeyResponse[]>()
readonly keys$ = merge(from(this.api.getSshKeys({})), this.local$)
protected tableKeys = viewChild<SSHTableComponent<SSHKey>>('table')
protected tableKeys = viewChild<SSHTableComponent<T.SshKeyResponse>>('table')
async add(all: readonly SSHKey[]) {
async add(all: readonly T.SshKeyResponse[]) {
const spec = ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Public Key'),
@@ -150,7 +149,7 @@ export default class SystemSSHComponent {
})
}
remove(all: readonly SSHKey[]) {
remove(all: readonly T.SshKeyResponse[]) {
this.dialogs
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))

View File

@@ -11,7 +11,7 @@ import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { SSHKey } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
@Component({
selector: '[keys]',
@@ -151,10 +151,12 @@ import { SSHKey } from 'src/app/services/api/api.types'
i18nPipe,
],
})
export class SSHTableComponent<T extends SSHKey> implements OnChanges {
readonly keys = input<readonly T[] | null>(null)
export class SSHTableComponent<
K extends T.SshKeyResponse,
> implements OnChanges {
readonly keys = input<readonly K[] | null>(null)
readonly selected = signal<readonly T[]>([])
readonly selected = signal<readonly K[]>([])
readonly all = computed(
() =>
!!this.selected()?.length &&
@@ -165,7 +167,7 @@ export class SSHTableComponent<T extends SSHKey> implements OnChanges {
this.selected.set([])
}
onToggle(key: T) {
onToggle(key: K) {
if (this.selected().includes(key)) {
this.selected.update(selected => selected.filter(s => s !== key))
} else {

View File

@@ -12,10 +12,7 @@ import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import {
getPublicDomains,
InterfaceService,
} from 'src/app/routes/portal/components/interfaces/interface.service'
import { InterfaceService } from 'src/app/routes/portal/components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -74,35 +71,32 @@ export default class StartOsUiComponent {
},
}
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly network = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo', 'network'),
this.patch.watch$('serverInfo', 'network'),
)
readonly allPackageData = toSignal(this.patch.watch$('packageData'))
readonly ui = computed(() => {
const network = this.network()
const gateways = this.gatewayService.gateways()
if (!network || !gateways) return
const binding = network.host.bindings['80']
return {
...this.iface,
addresses: this.interfaceService.getAddresses(
gatewayGroups: this.interfaceService.getGatewayGroups(
this.iface,
network.host,
gateways,
),
gateways: gateways.map(g => ({
enabled:
(g.public
? binding?.net.publicEnabled.includes(g.id)
: !binding?.net.privateDisabled.includes(g.id)) ?? false,
...g,
})),
torDomains: network.host.onions,
publicDomains: getPublicDomains(network.host.publicDomains, gateways),
privateDomains: network.host.privateDomains,
pluginGroups: this.interfaceService.getPluginGroups(
this.iface,
network.host,
this.allPackageData(),
),
addSsl: true,
}
})

View File

@@ -1,12 +1,11 @@
import { AvailableWifi } from 'src/app/services/api/api.types'
import { RR } from 'src/app/services/api/api.types'
import { T } from '@start9labs/start-sdk'
export interface WiFiForm {
ssid: string
password: string
}
export interface Wifi extends AvailableWifi {
export interface Wifi extends T.WifiListOut {
readonly connected?: boolean
}
@@ -15,7 +14,7 @@ export interface WifiData {
available: readonly Wifi[]
}
export function parseWifi(res: RR.GetWifiRes): WifiData {
export function parseWifi(res: T.WifiListInfo): WifiData {
return {
available: res.availableWifi,
known: Object.entries(res.ssids).map(([ssid, strength]) => ({

View File

@@ -54,7 +54,7 @@ import { wifiSpec } from './wifi.const'
tuiIconButton
size="xs"
docsLink
path="/user-manual/wifi.html"
path="/start-os/user-manual/wifi.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -148,7 +148,7 @@ export default class SystemWifiComponent {
.subscribe()
try {
await this.api.enableWifi({ enable })
await this.api.enableWifi({ enabled: enable })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -187,9 +187,8 @@ export default class SystemWifiComponent {
await this.api.addWifi({
ssid,
password,
priority: 0,
connect: true,
})
await this.api.connectWifi({ ssid })
} else {
await this.api.connectWifi({ ssid })
}
@@ -263,8 +262,6 @@ export default class SystemWifiComponent {
await this.api.addWifi({
ssid,
password,
priority: 0,
connect: false,
})
wifi.known = wifi.known.concat({
ssid,

View File

@@ -183,6 +183,7 @@ import UpdatesComponent from './updates.component'
&:last-child {
white-space: nowrap;
text-align: right;
}
&[colspan]:only-child {

View File

@@ -1,12 +1,13 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
getErrorMessage,
i18nKey,
LoadingService,
} from '@start9labs/shared'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { ACTION_CONFIRM_MODAL } from 'src/app/routes/portal/routes/services/modals/action-confirm.component'
import {
ActionInputModal,
PackageActionData,
@@ -21,7 +22,6 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
export class ActionService {
private readonly api = inject(ApiService)
private readonly dialog = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly formDialog = inject(FormDialogService)
@@ -36,14 +36,10 @@ export class ActionService {
} else {
if (actionInfo.metadata.warning) {
this.dialog
.openConfirm({
label: 'Warning',
.openComponent<boolean>(ACTION_CONFIRM_MODAL, {
label: actionInfo.metadata.name as i18nKey,
size: 's',
data: {
no: 'Cancel',
yes: 'Run',
content: actionInfo.metadata.warning as i18nKey,
},
data,
})
.pipe(filter(Boolean))
.subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id))
@@ -64,9 +60,9 @@ export class ActionService {
try {
const res = await this.api.runAction({
packageId,
eventId,
actionId,
input: input ?? null,
eventId,
})
if (!res) return
@@ -84,7 +80,9 @@ export class ActionService {
.subscribe()
}
} catch (e: any) {
this.errorService.handleError(e)
this.dialog
.openAlert(getErrorMessage(e) as i18nKey, { label: 'Error' })
.subscribe()
} finally {
loader.unsubscribe()
}

View File

@@ -2,9 +2,8 @@ import {
InstalledState,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { RR, ServerMetrics, ServerNotifications } from './api.types'
import { ActionRes } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON, REGISTRY_ICON } from './api-icons'
import { Log } from '@start9labs/shared'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { T, ISB, IST } from '@start9labs/start-sdk'
import { GetPackagesRes } from '@start9labs/marketplace'
@@ -30,7 +29,7 @@ export namespace Mock {
shuttingDown: false,
}
export const RegistryOSUpdate: RR.CheckOsUpdateRes = {
export const RegistryOSUpdate: T.OsVersionInfoMap = {
'0.4.1': {
headline: 'v0.4.1',
releaseNotes: 'Testing some release notes',
@@ -232,12 +231,11 @@ export namespace Mock {
},
releaseNotes: 'Taproot, Schnorr, and more.',
license: 'MIT',
wrapperRepo: 'https://github.com/start9labs/bitcoind-wrapper',
packageRepo: 'https://github.com/start9labs/bitcoind-wrapper',
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
marketingUrl: 'https://bitcoin.org',
donationUrl: 'https://start9.com',
docsUrl: 'https://docs.start9.com',
docsUrls: ['https://docs.start9.com'],
alerts: {
install: 'Bitcoin can take over a week to sync.',
uninstall:
@@ -264,6 +262,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const MockManifestLnd: T.Manifest = {
@@ -280,12 +279,11 @@ export namespace Mock {
},
releaseNotes: 'Dual funded channels!',
license: 'MIT',
wrapperRepo: 'https://github.com/start9labs/lnd-wrapper',
packageRepo: 'https://github.com/start9labs/lnd-wrapper',
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/',
marketingSite: 'https://lightning.engineering/',
marketingUrl: 'https://lightning.engineering/',
donationUrl: null,
docsUrl: 'https://docs.start9.com',
docsUrls: ['https://docs.start9.com'],
alerts: {
install: null,
uninstall: null,
@@ -324,6 +322,54 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const MockManifestTor: T.Manifest = {
id: 'tor',
title: 'Tor',
version: '0.4.8:0',
satisfies: [],
canMigrateTo: '!',
canMigrateFrom: '*',
gitHash: 'torhash1',
description: {
short: 'An anonymous overlay network.',
long: 'Tor provides anonymous communication by directing traffic through a free, worldwide overlay network.',
},
releaseNotes: 'Bug fixes and stability improvements.',
license: 'BSD-3-Clause',
packageRepo: 'https://github.com/start9labs/tor-wrapper',
upstreamRepo: 'https://gitlab.torproject.org/tpo/core/tor',
marketingUrl: 'https://www.torproject.org',
donationUrl: null,
docsUrls: ['https://docs.start9.com'],
alerts: {
install: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
osVersion: '0.2.12',
sdkVersion: '0.4.0',
dependencies: {},
images: {
main: {
source: 'packed',
arch: ['x86_64', 'aarch64'],
emulateMissingAs: 'aarch64',
nvidiaContainer: false,
},
},
volumes: ['main'],
hardwareRequirements: {
device: [],
arch: null,
ram: null,
},
hardwareAcceleration: false,
plugins: ['url-v0'],
}
export const MockManifestBitcoinProxy: T.Manifest = {
@@ -340,12 +386,11 @@ export namespace Mock {
},
releaseNotes: 'Even better support for Bitcoin and wallets!',
license: 'MIT',
wrapperRepo: 'https://github.com/start9labs/btc-rpc-proxy-wrapper',
packageRepo: 'https://github.com/start9labs/btc-rpc-proxy-wrapper',
upstreamRepo: 'https://github.com/Kixunil/btc-rpc-proxy',
supportSite: '',
marketingSite: '',
marketingUrl: '',
donationUrl: 'https://start9.com',
docsUrl: 'https://docs.start9.com',
docsUrls: ['https://docs.start9.com'],
alerts: {
install: 'Testing install alert',
uninstall: null,
@@ -377,6 +422,7 @@ export namespace Mock {
ram: null,
},
hardwareAcceleration: false,
plugins: [],
}
export const BitcoinDep: T.DependencyMetadata = {
@@ -403,14 +449,13 @@ export namespace Mock {
title: 'Bitcoin Core',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoind-startos',
packageRepo: 'https://github.com/start9labs/bitcoind-startos',
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
marketingUrl: 'https://bitcoin.org',
docsUrls: ['https://bitcoin.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -437,6 +482,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -445,14 +491,13 @@ export namespace Mock {
long: 'Bitcoin Knots is a combined Bitcoin node and wallet. Not only is it easy to use, but it also ensures bitcoins you receive are both real bitcoins and really yours.',
},
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoinknots-startos',
packageRepo: 'https://github.com/start9labs/bitcoinknots-startos',
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
marketingUrl: 'https://bitcoinknots.org',
docsUrls: ['https://bitcoinknots.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -479,6 +524,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -497,14 +543,13 @@ export namespace Mock {
title: 'Bitcoin Core',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoind-startos',
packageRepo: 'https://github.com/start9labs/bitcoind-startos',
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
marketingUrl: 'https://bitcoin.org',
docsUrls: ['https://bitcoin.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -531,6 +576,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:26.1.20240325:0': {
title: 'Bitcoin Knots',
@@ -539,14 +585,13 @@ export namespace Mock {
long: 'Bitcoin Knots is a combined Bitcoin node and wallet. Not only is it easy to use, but it also ensures bitcoins you receive are both real bitcoins and really yours.',
},
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoinknots-startos',
packageRepo: 'https://github.com/start9labs/bitcoinknots-startos',
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
marketingUrl: 'https://bitcoinknots.org',
docsUrls: ['https://bitcoinknots.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -573,6 +618,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -593,14 +639,13 @@ export namespace Mock {
title: 'LND',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/lnd-startos',
packageRepo: 'https://github.com/start9labs/lnd-startos',
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
marketingUrl: 'https://lightning.engineering/',
docsUrls: ['https://lightning.engineering/'],
releaseNotes: 'Upstream release to 0.17.5',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -630,6 +675,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -648,14 +694,13 @@ export namespace Mock {
title: 'LND',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/lnd-startos',
packageRepo: 'https://github.com/start9labs/lnd-startos',
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
marketingUrl: 'https://lightning.engineering/',
docsUrls: ['https://lightning.engineering/'],
releaseNotes: 'Upstream release to 0.17.4',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -685,6 +730,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -707,14 +753,13 @@ export namespace Mock {
title: 'Bitcoin Core',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoind-startos',
packageRepo: 'https://github.com/start9labs/bitcoind-startos',
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
marketingUrl: 'https://bitcoin.org',
docsUrls: ['https://bitcoin.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -741,6 +786,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
'#knots:27.1.0:0': {
title: 'Bitcoin Knots',
@@ -749,14 +795,13 @@ export namespace Mock {
long: 'Bitcoin Knots is a combined Bitcoin node and wallet. Not only is it easy to use, but it also ensures bitcoins you receive are both real bitcoins and really yours.',
},
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/bitcoinknots-startos',
packageRepo: 'https://github.com/start9labs/bitcoinknots-startos',
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
marketingUrl: 'https://bitcoinknots.org',
docsUrls: [],
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -783,6 +828,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin', 'featured'],
@@ -801,14 +847,13 @@ export namespace Mock {
title: 'LND',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/start9labs/lnd-startos',
packageRepo: 'https://github.com/start9labs/lnd-startos',
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
marketingUrl: 'https://lightning.engineering/',
docsUrls: [],
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -838,6 +883,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['lightning'],
@@ -856,14 +902,13 @@ export namespace Mock {
title: 'Bitcoin Proxy',
description: mockDescription,
license: 'mit',
wrapperRepo: 'https://github.com/Start9Labs/btc-rpc-proxy-wrappers',
packageRepo: 'https://github.com/Start9Labs/btc-rpc-proxy-wrappers',
upstreamRepo: 'https://github.com/Kixunil/btc-rpc-proxy',
supportSite: 'https://github.com/Kixunil/btc-rpc-proxy/issues',
docsUrl: 'https://github.com/Kixunil/btc-rpc-proxy',
marketingSite: '',
docsUrls: [],
marketingUrl: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.48',
sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,
@@ -892,6 +937,7 @@ export namespace Mock {
],
],
hardwareAcceleration: false,
plugins: [],
},
},
categories: ['bitcoin'],
@@ -899,7 +945,7 @@ export namespace Mock {
},
}
export const Notifications: ServerNotifications = [
export const Notifications: T.NotificationWithId[] = [
{
id: 1,
packageId: null,
@@ -974,7 +1020,7 @@ export namespace Mock {
},
]
export function getMetrics(): ServerMetrics {
export function getMetrics(): T.Metrics {
return {
general: {
temperature: {
@@ -1055,7 +1101,7 @@ export namespace Mock {
}
}
export const ServerLogs: Log[] = [
export const ServerLogs: T.LogEntry[] = [
{
timestamp: '2022-07-28T03:52:54.808769Z',
message: '****** START *****',
@@ -1079,7 +1125,7 @@ export namespace Mock {
},
]
export const Sessions: RR.GetSessionsRes = {
export const Sessions: T.SessionList = {
current: 'b7b1a9cef4284f00af9e9dda6e676177',
sessions: {
'9513226517c54ddd8107d6d7b9d8aed7': {
@@ -1101,7 +1147,7 @@ export namespace Mock {
},
}
export const SshKeys: RR.GetSSHKeysRes = [
export const SshKeys: T.SshKeyResponse[] = [
{
createdAt: new Date().toISOString(),
alg: 'ed25519',
@@ -1116,14 +1162,14 @@ export namespace Mock {
},
]
export const SshKey: RR.AddSSHKeyRes = {
export const SshKey: T.SshKeyResponse = {
createdAt: new Date().toISOString(),
alg: 'ed25519',
hostname: 'Lucy Key',
fingerprint: '44:44:7e:78:61:b4:bf:g2:de:24:15:96:4e:d4:15:53',
}
export const Wifi: RR.GetWifiRes = {
export const Wifi: T.WifiListInfo = {
ethernet: true,
ssids: {
Goosers: 50,
@@ -1150,7 +1196,7 @@ export namespace Mock {
],
}
export const BackupTargets: RR.GetBackupTargetsRes = {
export const BackupTargets: { [id: string]: T.BackupTarget } = {
hsbdjhasbasda: {
type: 'cifs',
hostname: 'smb://192.169.10.0',
@@ -1195,6 +1241,7 @@ export namespace Mock {
used: 100000000000,
model: null,
vendor: 'SSK',
guid: null,
startOs: {
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
@@ -1338,7 +1385,7 @@ export namespace Mock {
// },
// ]
export const BackupInfo: RR.GetBackupInfoRes = {
export const BackupInfo: T.BackupInfo = {
version: '0.3.6',
timestamp: new Date().toISOString(),
packageBackups: {
@@ -1357,7 +1404,7 @@ export namespace Mock {
},
}
export const ActionResMessage: RR.ActionRes = {
export const ActionResMessage: ActionRes = {
version: '1',
title: 'New Password',
message:
@@ -1365,7 +1412,7 @@ export namespace Mock {
result: null,
}
export const ActionResSingle: RR.ActionRes = {
export const ActionResSingle: ActionRes = {
version: '1',
title: 'New Password',
message:
@@ -1379,7 +1426,7 @@ export namespace Mock {
},
}
export const ActionResGroup: RR.ActionRes = {
export const ActionResGroup: ActionRes = {
version: '1',
title: 'Properties',
message:
@@ -1444,6 +1491,31 @@ export namespace Mock {
},
}
export const getCreateOnionServiceSpec = async (): Promise<IST.InputSpec> =>
configBuilderToSpec(
ISB.InputSpec.of({
ssl: ISB.Value.toggle({
name: 'SSL',
description: 'Enable HTTPS for this onion service',
default: true,
}),
privateKey: ISB.Value.text({
name: 'Private Key',
description:
'Optionally provide an existing ed25519 private key to reuse a .onion address. Leave blank to generate a new one.',
required: false,
default: null,
masked: true,
}),
urlPluginMetadata: ISB.Value.hidden<{
packageId: string
interfaceId: string
hostId: string
internalPort: number
}>(),
}),
)
export const getActionInputSpec = async (): Promise<IST.InputSpec> =>
configBuilderToSpec(
ISB.InputSpec.of({
@@ -2126,8 +2198,50 @@ export namespace Mock {
net: {
assignedPort: 80,
assignedSslPort: 443,
publicEnabled: [],
privateDisabled: [],
},
addresses: {
enabled: [],
disabled: [],
available: [
{
ssl: true,
public: false,
hostname: 'adjective-noun.local',
port: 1234,
metadata: {
kind: 'mdns',
gateways: ['eth0', 'wlan0'],
},
},
{
ssl: true,
public: false,
hostname: '192.168.10.11',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: true,
public: false,
hostname: '10.0.0.2',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: true,
public: false,
hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
ssl: true,
public: false,
hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:1234',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
],
},
options: {
addSsl: null,
@@ -2137,88 +2251,8 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
onions: [],
hostnameInfo: {
80: [
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.10.11',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]',
scopeId: 3,
port: null,
sslPort: 1234,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-p2p.onion',
port: 80,
sslPort: 443,
},
},
],
},
privateDomains: {},
portForwards: [],
},
bcdefgh: {
bindings: {
@@ -2227,8 +2261,11 @@ export namespace Mock {
net: {
assignedPort: 8332,
assignedSslPort: null,
publicEnabled: [],
privateDisabled: [],
},
addresses: {
enabled: [],
disabled: [],
available: [],
},
options: {
addSsl: null,
@@ -2238,11 +2275,8 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
onions: [],
hostnameInfo: {
8332: [],
},
privateDomains: {},
portForwards: [],
},
cdefghi: {
bindings: {
@@ -2251,8 +2285,11 @@ export namespace Mock {
net: {
assignedPort: 8333,
assignedSslPort: null,
publicEnabled: [],
privateDisabled: [],
},
addresses: {
enabled: [],
disabled: [],
available: [],
},
options: {
addSsl: null,
@@ -2262,16 +2299,15 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
onions: [],
hostnameInfo: {
8333: [],
},
privateDomains: {},
portForwards: [],
},
},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
'bitcoind-config': {
task: {
@@ -2338,8 +2374,10 @@ export namespace Mock {
},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {},
}
@@ -2444,8 +2482,10 @@ export namespace Mock {
},
hosts: {},
storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
plugin: { url: null },
tasks: {
config: {
active: true,

View File

@@ -1,531 +1,103 @@
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import {
FetchLogsReq,
FetchLogsRes,
FullKeyboard,
SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { IST, T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import {
GetPackageReq,
GetPackageRes,
GetPackagesReq,
GetPackagesRes,
} from '@start9labs/marketplace'
import { GetPackageReq, GetPackagesReq } from '@start9labs/marketplace'
export namespace RR {
// websocket
// websocket
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
export type WebsocketConfig<U> = Omit<WebSocketSubjectConfig<U>, 'url'>
// state
// state
export type EchoReq = { message: string } // server.echo
export type EchoRes = string
export type ServerState = 'initializing' | 'error' | 'running'
export type ServerState = 'initializing' | 'error' | 'running'
// diagnostic
// DB
export type SubscribePatchReq = {}
export type SubscribePatchRes = {
dump: Dump<DataModel>
guid: string
}
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
export type SetDBValueRes = null
// auth
export type LoginReq = {
password: string
ephemeral?: boolean
} // auth.login - unauthed
export type loginRes = null
export type LogoutReq = {} // auth.logout
export type LogoutRes = null
export type ResetPasswordReq = {
oldPassword: string
newPassword: string
} // auth.reset-password
export type ResetPasswordRes = null
// diagnostic
export type DiagnosticErrorRes = {
code: number
message: string
data: { details: string }
}
// init
export type InitFollowProgressRes = {
progress: T.FullProgress
guid: string
}
// server
export type GetSystemTimeReq = {} // server.time
export type GetSystemTimeRes = {
now: string
uptime: number // seconds
}
export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs & net.tor.logs
export type GetServerLogsRes = FetchLogsRes
export type FollowServerLogsReq = {
limit?: number // (optional) default is 50. Ignored if cursor provided
boot?: number | string | null // (optional) number is offset (0: current, -1 prev, +1 first), string is a specific boot id, null is all. Default is undefined
cursor?: string // the last known log. Websocket will return all logs since this log
} // server.logs.follow & server.kernel-logs.follow & net.tor.follow-logs
export type FollowServerLogsRes = {
startCursor: string
guid: string
}
export type FollowServerMetricsReq = {} // server.metrics.follow
export type FollowServerMetricsRes = {
guid: string
metrics: ServerMetrics
}
export type UpdateServerReq = { registry: string; targetVersion: string } // server.update
export type UpdateServerRes = 'updating' | 'no-updates'
export type RestartServerReq = {} // server.restart
export type RestartServerRes = null
export type ShutdownServerReq = {} // server.shutdown
export type ShutdownServerRes = null
export type DiskRepairReq = {} // server.disk.repair
export type DiskRepairRes = null
export type SetDnsReq = {
servers: string[] | null
} // net.dns.set-static
export type SetDnsRes = null
export type QueryDnsReq = {
fqdn: string
} // net.dns.query
export type QueryDnsRes = string | null
export type ResetTorReq = {
wipeState: boolean
reason: string
} // net.tor.reset
export type ResetTorRes = null
export type SetKeyboardReq = FullKeyboard // server.set-keyboard
export type SetKeyboardRes = null
export type SetLanguageReq = SetLanguageParams // server.set-language
export type SetLanguageRes = null
// smtp
export type SetSMTPReq = T.SmtpValue // server.set-smtp
export type SetSMTPRes = null
export type ClearSMTPReq = {} // server.clear-smtp
export type ClearSMTPRes = null
export type TestSMTPReq = SetSMTPReq & { to: string } // server.test-smtp
export type TestSMTPRes = null
// sessions
export type GetSessionsReq = {} // sessions.list
export type GetSessionsRes = {
current: string
sessions: { [hash: string]: Session }
}
export type KillSessionsReq = { ids: string[] } // sessions.kill
export type KillSessionsRes = null
// notification
export type GetNotificationsReq = {
before?: number
limit?: number
} // notification.list
export type GetNotificationsRes = ServerNotification<number>[]
export type DeleteNotificationsReq = { ids: number[] } // notification.remove
export type DeleteNotificationsRes = null
export type MarkSeenNotificationReq = DeleteNotificationsReq // notification.mark-seen
export type MarkSeenNotificationRes = null
export type MarkSeenAllNotificationsReq = { before: number } // notification.mark-seen-before
export type MarkSeenAllNotificationsRes = null
export type MarkUnseenNotificationReq = DeleteNotificationsReq // notification.mark-unseen
export type MarkUnseenNotificationRes = null
// wifi
export type GetWifiReq = {}
export type GetWifiRes = {
ssids: {
[ssid: string]: number
}
connected: string | null
country: string | null
ethernet: boolean
availableWifi: AvailableWifi[]
}
export type AddWifiReq = {
// wifi.add
ssid: string
password: string
priority: number
connect: boolean
}
export type AddWifiRes = null
export type EnabledWifiReq = { enable: boolean } // wifi.set-enabled
export type EnabledWifiRes = null
export type SetWifiCountryReq = { country: string } // wifi.country.set
export type SetWifiCountryRes = null
export type ConnectWifiReq = { ssid: string } // wifi.connect
export type ConnectWifiRes = null
export type DeleteWifiReq = { ssid: string } // wifi.remove
export type DeleteWifiRes = null
// ssh
export type GetSSHKeysReq = {} // ssh.list
export type GetSSHKeysRes = SSHKey[]
export type AddSSHKeyReq = { key: string } // ssh.add
export type AddSSHKeyRes = SSHKey
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.remove
export type DeleteSSHKeyRes = null
// backup
export type GetBackupTargetsReq = {} // backup.target.list
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
export type AddBackupTargetReq = {
// backup.target.cifs.add
hostname: string
path: string
username: string
password: string | null
}
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null
export type GetBackupInfoReq = {
// backup.target.info
targetId: string
serverId: string
password: string
}
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = {
// backup.create
targetId: string
packageIds: string[]
oldPassword: string | null
password: string
}
export type CreateBackupRes = null
// network
export type AddTunnelReq = {
name: string
config: string // file contents
public: boolean
} // net.tunnel.add
export type AddTunnelRes = {
id: string
}
export type UpdateTunnelReq = {
id: string
name: string
} // net.gateway.set-name
export type UpdateTunnelRes = null
export type RemoveTunnelReq = { id: string } // net.tunnel.remove
export type RemoveTunnelRes = null
export type InitAcmeReq = {
provider: string
contact: string[]
}
export type InitAcmeRes = null
export type RemoveAcmeReq = {
provider: string
}
export type RemoveAcmeRes = null
export type AddTorKeyReq = {
// net.tor.key.add
key: string
}
export type GenerateTorKeyReq = {} // net.tor.key.generate
export type AddTorKeyRes = string // onion address *with* .onion suffix
export type ServerBindingToggleGatewayReq = {
// server.host.binding.set-gateway-enabled
gateway: T.GatewayId
internalPort: 80
enabled: boolean
}
export type ServerBindingToggleGatewayRes = null
export type ServerAddOnionReq = {
// server.host.address.onion.add
onion: string // address *with* .onion suffix
}
export type AddOnionRes = null
export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove
export type RemoveOnionRes = null
export type OsUiAddPublicDomainReq = {
// server.host.address.domain.public.add
fqdn: string // FQDN
gateway: T.GatewayId
acme: string | null // URL. null means local Root CA
}
export type OsUiAddPublicDomainRes = QueryDnsRes
export type OsUiRemovePublicDomainReq = {
// server.host.address.domain.public.remove
fqdn: string // FQDN
}
export type OsUiRemovePublicDomainRes = null
export type OsUiAddPrivateDomainReq = {
// server.host.address.domain.private.add
fqdn: string // FQDN
}
export type OsUiAddPrivateDomainRes = null
export type OsUiRemovePrivateDomainReq = {
// server.host.address.domain.private.remove
fqdn: string // FQDN
}
export type OsUiRemovePrivateDomainRes = null
export type PkgBindingToggleGatewayReq = Omit<
ServerBindingToggleGatewayReq,
'internalPort'
> & {
// package.host.binding.set-gateway-enabled
internalPort: number
package: T.PackageId // string
host: T.HostId // string
}
export type PkgBindingToggleGatewayRes = null
export type PkgAddOnionReq = ServerAddOnionReq & {
// package.host.address.onion.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove
export type PkgAddPublicDomainReq = OsUiAddPublicDomainReq & {
// package.host.address.domain.public.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddPublicDomainRes = OsUiAddPublicDomainRes
export type PkgRemovePublicDomainReq = OsUiRemovePublicDomainReq & {
// package.host.address.domain.public.remove
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemovePublicDomainRes = OsUiRemovePublicDomainRes
export type PkgAddPrivateDomainReq = OsUiAddPrivateDomainReq & {
// package.host.address.domain.private.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddPrivateDomainRes = OsUiAddPrivateDomainRes
export type PkgRemovePrivateDomainReq = PkgAddPrivateDomainReq
export type PkgRemovePrivateDomainRes = OsUiRemovePrivateDomainRes
export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = FetchLogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
export type CancelInstallPackageReq = { id: string }
export type CancelInstallPackageRes = null
export type GetActionInputReq = { packageId: string; actionId: string } // package.action.get-input
export type GetActionInputRes = {
eventId: string
spec: IST.InputSpec
value: object | null
}
export type ActionReq = {
packageId: string
eventId: string | null
actionId: string
input: object | null
} // package.action.run
export type ActionRes = (T.ActionResult & { version: '1' }) | null
export type ClearTaskReq = {
packageId: string
replayId: string
} // package.action.clear-task
export type ClearTaskRes = null
export type RestorePackagesReq = {
// package.backup.restore
ids: string[]
targetId: string
serverId: string
password: string
}
export type RestorePackagesRes = null
export type StartPackageReq = { id: string } // package.start
export type StartPackageRes = null
export type RestartPackageReq = { id: string } // package.restart
export type RestartPackageRes = null
export type StopPackageReq = { id: string } // package.stop
export type StopPackageRes = null
export type RebuildPackageReq = { id: string } // package.rebuild
export type RebuildPackageRes = null
export type UninstallPackageReq = {
id: string
force: boolean
soft: boolean
} // package.uninstall
export type UninstallPackageRes = null
export type SideloadPackageReq = {
manifest: T.Manifest
icon: string // base64
}
export type SideloadPackageRes = {
upload: string
progress: string // guid
}
// registry
/** these are returned in ASCENDING order. the newest available version will be the LAST in the object */
export type CheckOsUpdateReq = { registry: string; serverId: string }
export type CheckOsUpdateRes = { [version: string]: T.OsVersionInfo }
export type GetRegistryInfoReq = { registry: string }
export type GetRegistryInfoRes = T.RegistryInfo
export type GetRegistryPackageReq = GetPackageReq & { registry: string }
export type GetRegistryPackageRes = GetPackageRes
export type GetRegistryPackagesReq = GetPackagesReq & { registry: string }
export type GetRegistryPackagesRes = GetPackagesRes
export type DiagnosticErrorRes = {
code: number
message: string
data: { details: string }
}
interface MetricData {
value: string
unit: string
// logs
export type FollowServerLogsReq = Omit<T.LogsParams, 'before'>
// bindings
export type ServerBindingSetAddressEnabledReq = {
// server.host.binding.set-address-enabled
internalPort: 80
address: string // JSON-serialized HostnameInfo
enabled: boolean | null // null = reset to default
}
export type ServerMetrics = {
general: {
temperature: MetricData | null
}
memory: {
total: MetricData
percentageUsed: MetricData
used: MetricData
available: MetricData
zramTotal: MetricData
zramUsed: MetricData
zramAvailable: MetricData
}
cpu: {
percentageUsed: MetricData
idle: MetricData
userSpace: MetricData
kernelSpace: MetricData
wait: MetricData
}
disk: {
capacity: MetricData
percentageUsed: MetricData
used: MetricData
available: MetricData
}
export type PkgBindingSetAddressEnabledReq = Omit<
ServerBindingSetAddressEnabledReq,
'internalPort'
> & {
// package.host.binding.set-address-enabled
internalPort: number
package: T.PackageId // string
host: T.HostId // string
}
export type Session = {
loggedIn: string
lastActive: string
userAgent: string
// package domains
export type PkgAddPublicDomainReq = T.AddPublicDomainParams & {
// package.host.address.domain.public.add
package: T.PackageId // string
host: T.HostId // string
}
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export interface DiskBackupTarget {
type: 'disk'
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
startOs: Record<string, StartOSDiskInfo>
export type PkgRemovePublicDomainReq = T.RemoveDomainParams & {
// package.host.address.domain.public.remove
package: T.PackageId // string
host: T.HostId // string
}
export interface CifsBackupTarget {
type: 'cifs'
hostname: string
path: string
username: string
mountable: boolean
startOs: Record<string, StartOSDiskInfo>
export type PkgAddPrivateDomainReq = T.AddPrivateDomainParams & {
// package.host.address.domain.private.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemovePrivateDomainReq = T.RemoveDomainParams & {
// package.host.address.domain.private.remove
package: T.PackageId // string
host: T.HostId // string
}
// package logs
export type GetPackageLogsReq = T.LogsParams & { id: string } // package.logs
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
// actions
export type GetActionInputRes = {
eventId: string
spec: IST.InputSpec
value: object | null
}
export type ActionRes = (T.ActionResult & { version: '1' }) | null
// registry
export type GetRegistryPackageReq = GetPackageReq & { registry: string }
export type GetRegistryPackagesReq = GetPackagesReq & { registry: string }
// dns
// TODO: Replace with T.CheckDnsRes when SDK types are generated
export type CheckDnsRes = boolean
// backup
export type DiskBackupTarget = Extract<T.BackupTarget, { type: 'disk' }>
export type CifsBackupTarget = T.CifsBackupTarget & { type: 'cifs' }
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
export interface DiskRecoverySource {
@@ -541,74 +113,28 @@ export interface CifsRecoverySource {
password: string
}
export type BackupInfo = {
version: string
timestamp: string
packageBackups: {
[id: string]: PackageBackupInfo
}
}
// notifications
export type PackageBackupInfo = {
title: string
version: string
osVersion: string
timestamp: string
}
export type ServerSpecs = {
[key: string]: string | number
}
export type SSHKey = {
createdAt: string
alg: string
hostname: string
fingerprint: string
}
export type ServerNotifications = ServerNotification<number>[]
export type ServerNotification<T extends number> = {
export type ServerNotification<N extends number> = {
id: number
packageId: string | null
createdAt: string
code: T
level: NotificationLevel
code: N
level: T.NotificationLevel
title: string
message: string
data: NotificationData<T>
data: NotificationData<N>
seen: boolean
}
export type NotificationLevel = 'success' | 'info' | 'warning' | 'error'
export type NotificationData<T> = T extends 0
export type NotificationData<N> = N extends 0
? null
: T extends 1
? BackupReport
: T extends 2
: N extends 1
? T.BackupReport
: N extends 2
? string
: any
export type BackupReport = {
server: {
attempted: boolean
error: string | null
}
packages: {
[id: string]: {
error: string | null
}
}
}
export type AvailableWifi = {
ssid: string
strength: number
security: string[]
}
declare global {
type Stringified<T> = string & {
[P in keyof T]: T[P]
@@ -624,10 +150,6 @@ declare global {
}
}
export type Encrypted = {
encrypted: string
}
// @TODO 041
// export namespace RR041 {

View File

@@ -1,7 +1,29 @@
import { MarketplacePkg } from '@start9labs/marketplace'
import { FullKeyboard, SetLanguageParams } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { RR } from './api.types'
import { GetPackageRes, GetPackagesRes } from '@start9labs/marketplace'
import { Dump } from 'patch-db-client'
import { WebSocketSubject } from 'rxjs/webSocket'
import { DataModel } from '../patch-db/data-model'
import {
ActionRes,
CheckDnsRes,
CifsBackupTarget,
DiagnosticErrorRes,
FollowPackageLogsReq,
FollowServerLogsReq,
GetActionInputRes,
GetPackageLogsReq,
GetRegistryPackageReq,
GetRegistryPackagesReq,
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
ServerState,
WebsocketConfig,
} from './api.types'
export abstract class ApiService {
// http
@@ -19,222 +41,204 @@ export abstract class ApiService {
abstract openWebsocket$<T>(
guid: string,
config?: RR.WebsocketConfig<T>,
config?: WebsocketConfig<T>,
): WebSocketSubject<T>
// state
abstract echo(params: RR.EchoReq, url: string): Promise<RR.EchoRes>
abstract echo(params: T.EchoParams, url: string): Promise<string>
abstract getState(): Promise<RR.ServerState>
abstract getState(): Promise<ServerState>
// db
abstract subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes>
abstract subscribeToPatchDB(params: {}): Promise<{
dump: Dump<DataModel>
guid: string
}>
abstract setDbValue<T>(
pathArr: Array<string | number>,
value: T,
): Promise<RR.SetDBValueRes>
): Promise<null>
// auth
abstract login(params: RR.LoginReq): Promise<RR.loginRes>
abstract login(params: T.LoginParams): Promise<null>
abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes>
abstract logout(params: {}): Promise<null>
abstract getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes>
abstract getSessions(params: {}): Promise<T.SessionList>
abstract killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes>
abstract killSessions(params: T.KillParams): Promise<null>
abstract resetPassword(
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes>
abstract resetPassword(params: T.ResetPasswordParams): Promise<null>
// diagnostic
abstract diagnosticGetError(): Promise<RR.DiagnosticErrorRes>
abstract diagnosticGetError(): Promise<DiagnosticErrorRes>
abstract diagnosticRestart(): Promise<void>
abstract diagnosticForgetDrive(): Promise<void>
abstract diagnosticRepairDisk(): Promise<void>
abstract diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract diagnosticGetLogs(params: T.LogsParams): Promise<T.LogResponse>
// init
abstract initFollowProgress(): Promise<RR.InitFollowProgressRes>
abstract initFollowProgress(): Promise<T.SetupProgress>
abstract initFollowLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse>
// server
abstract getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes>
abstract getSystemTime(params: {}): Promise<T.TimeInfo>
abstract getServerLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract getServerLogs(params: T.LogsParams): Promise<T.LogResponse>
abstract getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
abstract getKernelLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract getKernelLogs(params: T.LogsParams): Promise<T.LogResponse>
abstract followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse>
abstract followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse>
abstract followServerMetrics(
params: RR.FollowServerMetricsReq,
): Promise<RR.FollowServerMetricsRes>
abstract followServerMetrics(params: {}): Promise<T.MetricsFollowResponse>
abstract updateServer(params: RR.UpdateServerReq): Promise<RR.UpdateServerRes>
abstract updateServer(params: {
registry: string
targetVersion: string
}): Promise<'updating' | 'no-updates'>
abstract restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes>
abstract restartServer(params: {}): Promise<null>
abstract shutdownServer(
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes>
abstract shutdownServer(params: {}): Promise<null>
abstract repairDisk(params: RR.DiskRepairReq): Promise<RR.DiskRepairRes>
abstract repairDisk(params: {}): Promise<null>
abstract toggleKiosk(enable: boolean): Promise<null>
abstract setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes>
abstract setHostname(params: T.SetServerHostnameParams): Promise<null>
abstract setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes>
abstract setKeyboard(params: FullKeyboard): Promise<null>
abstract setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes>
abstract setLanguage(params: SetLanguageParams): Promise<null>
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
abstract setDns(params: T.SetStaticDnsParams): Promise<null>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
abstract queryDns(params: T.QueryDnsParams): Promise<string | null>
abstract checkPort(params: T.CheckPortParams): Promise<T.CheckPortRes>
abstract checkDns(params: T.CheckDnsParams): Promise<CheckDnsRes>
// smtp
abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes>
abstract setSmtp(params: T.SmtpValue): Promise<null>
abstract clearSmtp(params: RR.ClearSMTPReq): Promise<RR.ClearSMTPRes>
abstract clearSmtp(params: {}): Promise<null>
abstract testSmtp(params: RR.TestSMTPReq): Promise<RR.TestSMTPRes>
abstract testSmtp(params: T.TestSmtpParams): Promise<null>
// marketplace URLs
abstract checkOSUpdate(
params: RR.CheckOsUpdateReq,
): Promise<RR.CheckOsUpdateRes>
abstract checkOSUpdate(params: {
registry: string
serverId: string
}): Promise<T.OsVersionInfoMap>
abstract getRegistryInfo(
params: RR.GetRegistryInfoReq,
): Promise<RR.GetRegistryInfoRes>
abstract getRegistryInfo(params: {
registry: string
}): Promise<T.RegistryInfo>
abstract getRegistryPackage(
params: RR.GetRegistryPackageReq,
): Promise<RR.GetRegistryPackageRes>
params: GetRegistryPackageReq,
): Promise<GetPackageRes>
abstract getRegistryPackages(
params: RR.GetRegistryPackagesReq,
): Promise<RR.GetRegistryPackagesRes>
params: GetRegistryPackagesReq,
): Promise<GetPackagesRes>
// notification
abstract getNotifications(
params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes>
params: T.ListNotificationParams,
): Promise<T.NotificationWithId[]>
abstract markSeenNotifications(
params: RR.MarkSeenNotificationReq,
): Promise<RR.MarkSeenNotificationRes>
params: T.ModifyNotificationParams,
): Promise<null>
abstract markSeenAllNotifications(
params: RR.MarkSeenAllNotificationsReq,
): Promise<RR.MarkSeenAllNotificationsRes>
params: T.ModifyNotificationBeforeParams,
): Promise<null>
abstract markUnseenNotifications(
params: RR.DeleteNotificationsReq,
): Promise<RR.DeleteNotificationsRes>
params: T.ModifyNotificationParams,
): Promise<null>
abstract deleteNotifications(
params: RR.DeleteNotificationsReq,
): Promise<RR.DeleteNotificationsRes>
params: T.ModifyNotificationParams,
): Promise<null>
// ** proxies **
abstract addTunnel(params: RR.AddTunnelReq): Promise<RR.AddTunnelRes>
abstract addTunnel(params: T.AddTunnelParams): Promise<{ id: string }>
abstract updateTunnel(params: RR.UpdateTunnelReq): Promise<RR.UpdateTunnelRes>
abstract updateTunnel(params: T.RenameGatewayParams): Promise<null>
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
abstract removeTunnel(params: T.RemoveTunnelParams): Promise<null>
abstract setDefaultOutbound(params: { gateway: string | null }): Promise<null>
abstract setServiceOutbound(params: T.SetOutboundGatewayParams): Promise<null>
// ** domains **
// wifi
abstract enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes>
abstract enableWifi(params: T.SetWifiEnabledParams): Promise<null>
abstract setWifiCountry(
params: RR.SetWifiCountryReq,
): Promise<RR.SetWifiCountryRes>
abstract setWifiCountry(params: T.SetCountryParams): Promise<null>
abstract getWifi(
params: RR.GetWifiReq,
timeout: number,
): Promise<RR.GetWifiRes>
abstract getWifi(params: {}, timeout: number): Promise<T.WifiListInfo>
abstract addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes>
abstract addWifi(params: T.WifiAddParams): Promise<null>
abstract connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
abstract connectWifi(params: T.WifiSsidParams): Promise<null>
abstract deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes>
abstract deleteWifi(params: T.WifiSsidParams): Promise<null>
// ssh
abstract getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes>
abstract getSshKeys(params: {}): Promise<T.SshKeyResponse[]>
abstract addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes>
abstract addSshKey(params: T.SshAddParams): Promise<T.SshKeyResponse>
abstract deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes>
abstract deleteSshKey(params: T.SshDeleteParams): Promise<null>
// backup
abstract getBackupTargets(
params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes>
abstract getBackupTargets(params: {}): Promise<{
[id: string]: T.BackupTarget
}>
abstract addBackupTarget(
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes>
params: T.CifsAddParams,
): Promise<{ [id: string]: CifsBackupTarget }>
abstract updateBackupTarget(
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes>
params: T.CifsUpdateParams,
): Promise<{ [id: string]: CifsBackupTarget }>
abstract removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes>
abstract removeBackupTarget(params: T.CifsRemoveParams): Promise<null>
abstract getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes>
abstract getBackupInfo(params: T.InfoParams): Promise<T.BackupInfo>
abstract createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
abstract createBackup(params: T.BackupParams): Promise<null>
// @TODO 041
@@ -286,51 +290,37 @@ export abstract class ApiService {
// package
abstract getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
abstract getPackageLogs(params: GetPackageLogsReq): Promise<T.LogResponse>
abstract followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes>
params: FollowPackageLogsReq,
): Promise<T.LogFollowResponse>
abstract installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
abstract installPackage(params: T.InstallParams): Promise<null>
abstract cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes>
abstract cancelInstallPackage(params: T.CancelInstallParams): Promise<null>
abstract getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes>
params: T.GetActionInputParams,
): Promise<GetActionInputRes>
abstract runAction(params: RR.ActionReq): Promise<RR.ActionRes>
abstract runAction(params: T.RunActionParams): Promise<ActionRes>
abstract clearTask(params: RR.ClearTaskReq): Promise<RR.ClearTaskRes>
abstract clearTask(params: T.ClearTaskParams): Promise<null>
abstract restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes>
abstract restorePackages(params: T.RestorePackageParams): Promise<null>
abstract startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes>
abstract startPackage(params: T.ControlParams): Promise<null>
abstract restartPackage(
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes>
abstract restartPackage(params: T.ControlParams): Promise<null>
abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes>
abstract stopPackage(params: T.ControlParams): Promise<null>
abstract rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes>
abstract rebuildPackage(params: T.RebuildParams): Promise<null>
abstract uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes>
abstract uninstallPackage(params: T.UninstallParams): Promise<null>
abstract sideloadPackage(): Promise<RR.SideloadPackageRes>
abstract sideloadPackage(): Promise<T.SideloadResponse>
// @TODO 041
@@ -340,65 +330,39 @@ export abstract class ApiService {
// params: RR.SetServiceOutboundTunnelReq,
// ): Promise<RR.SetServiceOutboundTunnelRes>
abstract initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes>
abstract initAcme(params: T.InitAcmeParams): Promise<null>
abstract removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes>
abstract removeAcme(params: T.RemoveAcmeParams): Promise<null>
abstract addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes>
abstract generateTorKey(
params: RR.GenerateTorKeyReq,
): Promise<RR.AddTorKeyRes>
abstract serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes>
abstract serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes>
abstract serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract serverBindingSetAddressEnabled(
params: ServerBindingSetAddressEnabledReq,
): Promise<null>
abstract osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
): Promise<RR.OsUiAddPublicDomainRes>
params: T.AddPublicDomainParams,
): Promise<string | null>
abstract osUiRemovePublicDomain(
params: RR.OsUiRemovePublicDomainReq,
): Promise<RR.OsUiRemovePublicDomainRes>
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
abstract osUiAddPrivateDomain(
params: RR.OsUiAddPrivateDomainReq,
): Promise<RR.OsUiAddPrivateDomainRes>
abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null>
abstract osUiRemovePrivateDomain(
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes>
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
abstract pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes>
abstract pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes>
abstract pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract pkgBindingSetAddressEnabled(
params: PkgBindingSetAddressEnabledReq,
): Promise<null>
abstract pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,
): Promise<RR.PkgAddPublicDomainRes>
params: PkgAddPublicDomainReq,
): Promise<string | null>
abstract pkgRemovePublicDomain(
params: RR.PkgRemovePublicDomainReq,
): Promise<RR.PkgRemovePublicDomainRes>
params: PkgRemovePublicDomainReq,
): Promise<null>
abstract pkgAddPrivateDomain(
params: RR.PkgAddPrivateDomainReq,
): Promise<RR.PkgAddPrivateDomainRes>
abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null>
abstract pkgRemovePrivateDomain(
params: RR.PkgRemovePrivateDomainReq,
): Promise<RR.PkgRemovePrivateDomainRes>
params: PkgRemovePrivateDomainReq,
): Promise<null>
}

View File

@@ -1,21 +1,42 @@
import { DOCUMENT, Inject, Injectable } from '@angular/core'
import { blake3 } from '@noble/hashes/blake3'
import { MarketplacePkg } from '@start9labs/marketplace'
import {
FullKeyboard,
HttpOptions,
HttpService,
isRpcError,
RpcError,
RPCOptions,
SetLanguageParams,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { GetPackageRes, GetPackagesRes } from '@start9labs/marketplace'
import { Dump, pathFromArray } from 'patch-db-client'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { AuthService } from '../auth.service'
import { DataModel } from '../patch-db/data-model'
import { RR } from './api.types'
import {
ActionRes,
CheckDnsRes,
CifsBackupTarget,
DiagnosticErrorRes,
FollowPackageLogsReq,
FollowServerLogsReq,
GetActionInputRes,
GetPackageLogsReq,
GetRegistryPackageReq,
GetRegistryPackagesReq,
PkgAddPrivateDomainReq,
PkgAddPublicDomainReq,
PkgBindingSetAddressEnabledReq,
PkgRemovePrivateDomainReq,
PkgRemovePublicDomainReq,
ServerBindingSetAddressEnabledReq,
ServerState,
WebsocketConfig,
} from './api.types'
import { ApiService } from './embassy-api.service'
@Injectable()
@@ -49,7 +70,7 @@ export class LiveApiService extends ApiService {
urls: string[],
params: Record<string, string | number>,
): Promise<string> {
for (let url in urls) {
for (const url of urls) {
try {
const res = await this.httpRequest<string>({
method: 'GET',
@@ -67,7 +88,7 @@ export class LiveApiService extends ApiService {
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T> = {},
config: WebsocketConfig<T> = {},
): WebSocketSubject<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
@@ -81,59 +102,58 @@ export class LiveApiService extends ApiService {
// state
async echo(params: RR.EchoReq, url: string): Promise<RR.EchoRes> {
async echo(params: T.EchoParams, url: string): Promise<string> {
return this.rpcRequest({ method: 'echo', params }, url)
}
async getState(): Promise<RR.ServerState> {
async getState(): Promise<ServerState> {
return this.rpcRequest({ method: 'state', params: {}, timeout: 10000 })
}
// db
async subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes> {
async subscribeToPatchDB(params: {}): Promise<{
dump: Dump<DataModel>
guid: string
}> {
return this.rpcRequest({ method: 'db.subscribe', params })
}
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
): Promise<RR.SetDBValueRes> {
): Promise<null> {
const pointer = pathFromArray(pathArr)
const params: RR.SetDBValueReq<T> = { pointer, value }
const params = { pointer, value }
return this.rpcRequest({ method: 'db.put.ui', params })
}
// auth
async login(params: RR.LoginReq): Promise<RR.loginRes> {
async login(params: T.LoginParams): Promise<null> {
return this.rpcRequest({ method: 'auth.login', params })
}
async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> {
async logout(params: {}): Promise<null> {
return this.rpcRequest({ method: 'auth.logout', params })
}
async getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
async getSessions(params: {}): Promise<T.SessionList> {
return this.rpcRequest({ method: 'auth.session.list', params })
}
async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
async killSessions(params: T.KillParams): Promise<null> {
return this.rpcRequest({ method: 'auth.session.kill', params })
}
async resetPassword(
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes> {
async resetPassword(params: T.ResetPasswordParams): Promise<null> {
return this.rpcRequest({ method: 'auth.reset-password', params })
}
// diagnostic
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
return this.rpcRequest<RR.DiagnosticErrorRes>({
async diagnosticGetError(): Promise<DiagnosticErrorRes> {
return this.rpcRequest<DiagnosticErrorRes>({
method: 'diagnostic.error',
params: {},
})
@@ -160,10 +180,8 @@ export class LiveApiService extends ApiService {
})
}
async diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.rpcRequest<RR.GetServerLogsRes>({
async diagnosticGetLogs(params: T.LogsParams): Promise<T.LogResponse> {
return this.rpcRequest<T.LogResponse>({
method: 'diagnostic.logs',
params,
})
@@ -171,81 +189,62 @@ export class LiveApiService extends ApiService {
// init
async initFollowProgress(): Promise<RR.InitFollowProgressRes> {
async initFollowProgress(): Promise<T.SetupProgress> {
return this.rpcRequest({ method: 'init.subscribe', params: {} })
}
async initFollowLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse> {
return this.rpcRequest({ method: 'init.logs.follow', params })
}
// server
async getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes> {
async getSystemTime(params: {}): Promise<T.TimeInfo> {
return this.rpcRequest({ method: 'server.time', params })
}
async getServerLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
async getServerLogs(params: T.LogsParams): Promise<T.LogResponse> {
return this.rpcRequest({ method: 'server.logs', params })
}
async getTorLogs(params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs', params })
}
async getKernelLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
async getKernelLogs(params: T.LogsParams): Promise<T.LogResponse> {
return this.rpcRequest({ method: 'server.kernel-logs', params })
}
async followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse> {
return this.rpcRequest({ method: 'server.logs.follow', params })
}
async followTorLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'net.tor.logs.follow', params })
}
async followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
params: FollowServerLogsReq,
): Promise<T.LogFollowResponse> {
return this.rpcRequest({ method: 'server.kernel-logs.follow', params })
}
async followServerMetrics(
params: RR.FollowServerMetricsReq,
): Promise<RR.FollowServerMetricsRes> {
async followServerMetrics(params: {}): Promise<T.MetricsFollowResponse> {
return this.rpcRequest({ method: 'server.metrics.follow', params })
}
async updateServer(params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
async updateServer(params: {
registry: string
targetVersion: string
}): Promise<'updating' | 'no-updates'> {
return this.rpcRequest({ method: 'server.update', params })
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
async restartServer(params: {}): Promise<null> {
return this.rpcRequest({ method: 'server.restart', params })
}
async shutdownServer(
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes> {
async shutdownServer(params: {}): Promise<null> {
return this.rpcRequest({ method: 'server.shutdown', params })
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
async repairDisk(params: {}): Promise<null> {
return this.rpcRequest({ method: 'disk.repair', params })
}
@@ -256,46 +255,59 @@ export class LiveApiService extends ApiService {
})
}
async setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes> {
async setHostname(params: T.SetServerHostnameParams): Promise<null> {
return this.rpcRequest({ method: 'server.set-hostname', params })
}
async setKeyboard(params: FullKeyboard): Promise<null> {
return this.rpcRequest({ method: 'server.set-keyboard', params })
}
async setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes> {
async setLanguage(params: SetLanguageParams): Promise<null> {
return this.rpcRequest({ method: 'server.set-language', params })
}
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
async setDns(params: T.SetStaticDnsParams): Promise<null> {
return this.rpcRequest({
method: 'net.dns.set-static',
params,
})
}
async queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes> {
async queryDns(params: T.QueryDnsParams): Promise<string | null> {
return this.rpcRequest({
method: 'net.dns.query',
params,
})
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
return this.rpcRequest({ method: 'net.tor.reset', params })
async checkPort(params: T.CheckPortParams): Promise<T.CheckPortRes> {
return this.rpcRequest({
method: 'net.gateway.check-port',
params,
})
}
async checkDns(params: T.CheckDnsParams): Promise<CheckDnsRes> {
return this.rpcRequest({
method: 'net.gateway.check-dns',
params,
})
}
// marketplace URLs
async checkOSUpdate(
params: RR.CheckOsUpdateReq,
): Promise<RR.CheckOsUpdateRes> {
async checkOSUpdate(params: {
registry: string
serverId: string
}): Promise<T.OsVersionInfoMap> {
return this.rpcRequest({
method: 'registry.os.version.get',
params,
})
}
async getRegistryInfo(
params: RR.GetRegistryInfoReq,
): Promise<RR.GetRegistryInfoRes> {
async getRegistryInfo(params: { registry: string }): Promise<T.RegistryInfo> {
return this.rpcRequest({
method: 'registry.info',
params,
@@ -303,8 +315,8 @@ export class LiveApiService extends ApiService {
}
async getRegistryPackage(
params: RR.GetRegistryPackageReq,
): Promise<RR.GetRegistryPackageRes> {
params: GetRegistryPackageReq,
): Promise<GetPackageRes> {
return this.rpcRequest({
method: 'registry.package.get',
params,
@@ -312,8 +324,8 @@ export class LiveApiService extends ApiService {
}
async getRegistryPackages(
params: RR.GetRegistryPackagesReq,
): Promise<RR.GetRegistryPackagesRes> {
params: GetRegistryPackagesReq,
): Promise<GetPackagesRes> {
return this.rpcRequest({
method: 'registry.package.get',
params,
@@ -323,26 +335,24 @@ export class LiveApiService extends ApiService {
// notification
async getNotifications(
params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes> {
params: T.ListNotificationParams,
): Promise<T.NotificationWithId[]> {
return this.rpcRequest({ method: 'notification.list', params })
}
async deleteNotifications(
params: RR.DeleteNotificationsReq,
): Promise<RR.DeleteNotificationsRes> {
async deleteNotifications(params: T.ModifyNotificationParams): Promise<null> {
return this.rpcRequest({ method: 'notification.remove', params })
}
async markSeenNotifications(
params: RR.MarkSeenNotificationReq,
): Promise<RR.MarkSeenNotificationRes> {
params: T.ModifyNotificationParams,
): Promise<null> {
return this.rpcRequest({ method: 'notification.mark-seen', params })
}
async markSeenAllNotifications(
params: RR.MarkSeenAllNotificationsReq,
): Promise<RR.MarkSeenAllNotificationsRes> {
params: T.ModifyNotificationBeforeParams,
): Promise<null> {
return this.rpcRequest({
method: 'notification.mark-seen-before',
params,
@@ -350,118 +360,120 @@ export class LiveApiService extends ApiService {
}
async markUnseenNotifications(
params: RR.MarkUnseenNotificationReq,
): Promise<RR.MarkUnseenNotificationRes> {
params: T.ModifyNotificationParams,
): Promise<null> {
return this.rpcRequest({ method: 'notification.mark-unseen', params })
}
// proxies
async addTunnel(params: RR.AddTunnelReq): Promise<RR.AddTunnelRes> {
async addTunnel(params: T.AddTunnelParams): Promise<{ id: string }> {
return this.rpcRequest({ method: 'net.tunnel.add', params })
}
async updateTunnel(params: RR.UpdateTunnelReq): Promise<RR.UpdateTunnelRes> {
async updateTunnel(params: T.RenameGatewayParams): Promise<null> {
return this.rpcRequest({ method: 'net.gateway.set-name', params })
}
async removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes> {
async removeTunnel(params: T.RemoveTunnelParams): Promise<null> {
return this.rpcRequest({ method: 'net.tunnel.remove', params })
}
async setDefaultOutbound(params: T.SetDefaultOutboundParams): Promise<null> {
return this.rpcRequest({
method: 'net.gateway.set-default-outbound',
params,
})
}
async setServiceOutbound(params: T.SetOutboundGatewayParams): Promise<null> {
return this.rpcRequest({ method: 'package.set-outbound-gateway', params })
}
// wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
async enableWifi(params: T.SetWifiEnabledParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.enable', params })
}
async getWifi(
params: RR.GetWifiReq,
timeout?: number,
): Promise<RR.GetWifiRes> {
async getWifi(params: {}, timeout?: number): Promise<T.WifiListInfo> {
return this.rpcRequest({ method: 'wifi.get', params, timeout })
}
async setWifiCountry(
params: RR.SetWifiCountryReq,
): Promise<RR.SetWifiCountryRes> {
async setWifiCountry(params: T.SetCountryParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.country.set', params })
}
async addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
async addWifi(params: T.WifiAddParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.add', params })
}
async connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
async connectWifi(params: T.WifiSsidParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.connect', params })
}
async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
async deleteWifi(params: T.WifiSsidParams): Promise<null> {
return this.rpcRequest({ method: 'wifi.remove', params })
}
// smtp
async setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes> {
async setSmtp(params: T.SmtpValue): Promise<null> {
return this.rpcRequest({ method: 'server.set-smtp', params })
}
async clearSmtp(params: RR.ClearSMTPReq): Promise<RR.ClearSMTPRes> {
async clearSmtp(params: {}): Promise<null> {
return this.rpcRequest({ method: 'server.clear-smtp', params })
}
async testSmtp(params: RR.TestSMTPReq): Promise<RR.TestSMTPRes> {
async testSmtp(params: T.TestSmtpParams): Promise<null> {
return this.rpcRequest({ method: 'server.test-smtp', params })
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
async getSshKeys(params: {}): Promise<T.SshKeyResponse[]> {
return this.rpcRequest({ method: 'ssh.list', params })
}
async addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
async addSshKey(params: T.SshAddParams): Promise<T.SshKeyResponse> {
return this.rpcRequest({ method: 'ssh.add', params })
}
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
async deleteSshKey(params: T.SshDeleteParams): Promise<null> {
return this.rpcRequest({ method: 'ssh.remove', params })
}
// backup
async getBackupTargets(
params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes> {
async getBackupTargets(params: {}): Promise<{
[id: string]: T.BackupTarget
}> {
return this.rpcRequest({ method: 'backup.target.list', params })
}
async addBackupTarget(
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
params: T.CifsAddParams,
): Promise<{ [id: string]: CifsBackupTarget }> {
params.path = params.path.replace('/\\/g', '/')
return this.rpcRequest({ method: 'backup.target.cifs.add', params })
}
async updateBackupTarget(
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
params: T.CifsUpdateParams,
): Promise<{ [id: string]: CifsBackupTarget }> {
return this.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> {
async removeBackupTarget(params: T.CifsRemoveParams): Promise<null> {
return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes> {
async getBackupInfo(params: T.InfoParams): Promise<T.BackupInfo> {
return this.rpcRequest({ method: 'backup.target.info', params })
}
async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
async createBackup(params: T.BackupParams): Promise<null> {
return this.rpcRequest({ method: 'backup.create', params })
}
@@ -524,77 +536,63 @@ export class LiveApiService extends ApiService {
// package
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
async getPackageLogs(params: GetPackageLogsReq): Promise<T.LogResponse> {
return this.rpcRequest({ method: 'package.logs', params })
}
async followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes> {
params: FollowPackageLogsReq,
): Promise<T.LogFollowResponse> {
return this.rpcRequest({ method: 'package.logs.follow', params })
}
async installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
async installPackage(params: T.InstallParams): Promise<null> {
return this.rpcRequest({ method: 'package.install', params })
}
async cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes> {
async cancelInstallPackage(params: T.CancelInstallParams): Promise<null> {
return this.rpcRequest({ method: 'package.cancel-install', params })
}
async getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes> {
params: T.GetActionInputParams,
): Promise<GetActionInputRes> {
return this.rpcRequest({ method: 'package.action.get-input', params })
}
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
async runAction(params: T.RunActionParams): Promise<ActionRes> {
return this.rpcRequest({ method: 'package.action.run', params })
}
async clearTask(params: RR.ClearTaskReq): Promise<RR.ClearTaskRes> {
async clearTask(params: T.ClearTaskParams): Promise<null> {
return this.rpcRequest({ method: 'package.action.clear-task', params })
}
async restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {
async restorePackages(params: T.RestorePackageParams): Promise<null> {
return this.rpcRequest({ method: 'package.backup.restore', params })
}
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
async startPackage(params: T.ControlParams): Promise<null> {
return this.rpcRequest({ method: 'package.start', params })
}
async restartPackage(
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> {
async restartPackage(params: T.ControlParams): Promise<null> {
return this.rpcRequest({ method: 'package.restart', params })
}
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
async stopPackage(params: T.ControlParams): Promise<null> {
return this.rpcRequest({ method: 'package.stop', params })
}
async rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes> {
async rebuildPackage(params: T.RebuildParams): Promise<null> {
return this.rpcRequest({ method: 'package.rebuild', params })
}
async uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> {
async uninstallPackage(params: T.UninstallParams): Promise<null> {
return this.rpcRequest({ method: 'package.uninstall', params })
}
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
async sideloadPackage(): Promise<T.SideloadResponse> {
return this.rpcRequest({
method: 'package.sideload',
params: {},
@@ -607,141 +605,85 @@ export class LiveApiService extends ApiService {
// return this.rpcRequest({ method: 'package.proxy.set-outbound', params })
// }
async removeAcme(params: RR.RemoveAcmeReq): Promise<RR.RemoveAcmeRes> {
async removeAcme(params: T.RemoveAcmeParams): Promise<null> {
return this.rpcRequest({
method: 'net.acme.remove',
params,
})
}
async initAcme(params: RR.InitAcmeReq): Promise<RR.InitAcmeRes> {
async initAcme(params: T.InitAcmeParams): Promise<null> {
return this.rpcRequest({
method: 'net.acme.init',
params,
})
}
async addTorKey(params: RR.AddTorKeyReq): Promise<RR.AddTorKeyRes> {
async serverBindingSetAddressEnabled(
params: ServerBindingSetAddressEnabledReq,
): Promise<null> {
return this.rpcRequest({
method: 'net.tor.key.add',
params,
})
}
async generateTorKey(params: RR.GenerateTorKeyReq): Promise<RR.AddTorKeyRes> {
return this.rpcRequest({
method: 'net.tor.key.generate',
params,
})
}
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
return this.rpcRequest({
method: 'server.host.binding.set-gateway-enabled',
params,
})
}
async serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.add',
params,
})
}
async serverRemoveOnion(
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'server.host.address.onion.remove',
method: 'server.host.binding.set-address-enabled',
params,
})
}
async osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
): Promise<RR.OsUiAddPublicDomainRes> {
params: T.AddPublicDomainParams,
): Promise<string | null> {
return this.rpcRequest({
method: 'server.host.address.domain.public.add',
params,
})
}
async osUiRemovePublicDomain(
params: RR.OsUiRemovePublicDomainReq,
): Promise<RR.OsUiRemovePublicDomainRes> {
async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
return this.rpcRequest({
method: 'server.host.address.domain.public.remove',
params,
})
}
async osUiAddPrivateDomain(
params: RR.OsUiAddPrivateDomainReq,
): Promise<RR.OsUiAddPrivateDomainRes> {
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> {
return this.rpcRequest({
method: 'server.host.address.domain.private.add',
params,
})
}
async osUiRemovePrivateDomain(
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes> {
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
return this.rpcRequest({
method: 'server.host.address.domain.private.remove',
params,
})
}
async pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes> {
async pkgBindingSetAddressEnabled(
params: PkgBindingSetAddressEnabledReq,
): Promise<null> {
return this.rpcRequest({
method: 'package.host.binding.set-gateway-enabled',
params,
})
}
async pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.add',
params,
})
}
async pkgRemoveOnion(
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes> {
return this.rpcRequest({
method: 'package.host.address.onion.remove',
method: 'package.host.binding.set-address-enabled',
params,
})
}
async pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,
): Promise<RR.PkgAddPublicDomainRes> {
params: PkgAddPublicDomainReq,
): Promise<string | null> {
return this.rpcRequest({
method: 'package.host.address.domain.public.add',
params,
})
}
async pkgRemovePublicDomain(
params: RR.PkgRemovePublicDomainReq,
): Promise<RR.PkgRemovePublicDomainRes> {
async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
return this.rpcRequest({
method: 'package.host.address.domain.public.remove',
params,
})
}
async pkgAddPrivateDomain(
params: RR.PkgAddPrivateDomainReq,
): Promise<RR.PkgAddPrivateDomainRes> {
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> {
return this.rpcRequest({
method: 'package.host.address.domain.private.add',
params,
@@ -749,8 +691,8 @@ export class LiveApiService extends ApiService {
}
async pkgRemovePrivateDomain(
params: RR.PkgRemovePrivateDomainReq,
): Promise<RR.PkgRemovePrivateDomainRes> {
params: PkgRemovePrivateDomainReq,
): Promise<null> {
return this.rpcRequest({
method: 'package.host.address.domain.private.remove',
params,

Some files were not shown because too many files have changed in this diff Show More