mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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: '',
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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') =>
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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') =>
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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]) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -183,6 +183,7 @@ import UpdatesComponent from './updates.component'
|
||||
|
||||
&:last-child {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&[colspan]:only-child {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user