mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Feature/start tunnel (#3037)
* fix live-build resolv.conf * improved debuggability * wip: start-tunnel * fixes for trixie and tor * non-free-firmware on trixie * wip * web server WIP * wip: tls refactor * FE patchdb, mocks, and most endpoints * fix editing records and patch mocks * refactor complete * finish api * build and formatter update * minor change toi viewing addresses and fix build * fixes * more providers * endpoint for getting config * fix tests * api fixes * wip: separate port forward controller into parts * simplify iptables rules * bump sdk * misc fixes * predict next subnet and ip, use wan ips, and form validation * refactor: break big components apart and address todos (#3043) * refactor: break big components apart and address todos * starttunnel readme, fix pf mocks, fix adding tor domain in startos --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> * better tui * tui tweaks * fix: address comments * better regex for subnet * fixes * better validation * handle rpc errors * build fixes * fix: address comments (#3044) * fix: address comments * fix unread notification mocks * fix row click for notification --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> * fix raspi build * fix build * fix build * fix build * fix build * try to fix build * fix tests * fix tests * fix rsync tests * delete useless effectful test --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
@@ -32,7 +32,7 @@ import { MarketplaceItemComponent } from './item.component'
|
||||
let-completeWith="completeWith"
|
||||
>
|
||||
<tui-radio-list [items]="versions()" [(ngModel)]="data.version" />
|
||||
<footer class="buttons">
|
||||
<footer class="g-buttons">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
|
||||
@@ -12,15 +12,15 @@ import { getErrorMessage } from '../services/error.service'
|
||||
@Component({
|
||||
template: `
|
||||
@if (error()) {
|
||||
<tui-notification appearance="negative" safeLinks>
|
||||
{{ error() }}
|
||||
</tui-notification>
|
||||
<tui-notification appearance="negative" safeLinks [innerHTML]="error()" />
|
||||
}
|
||||
|
||||
@if (content(); as result) {
|
||||
<div safeLinks [innerHTML]="result | markdown | dompurify"></div>
|
||||
} @else {
|
||||
<tui-loader textContent="Loading" [style.height.%]="100" />
|
||||
@if (!error()) {
|
||||
<tui-loader textContent="Loading" [style.height.%]="100" />
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -34,14 +34,10 @@ import { getErrorMessage } from '../services/error.service'
|
||||
],
|
||||
})
|
||||
export class MarkdownComponent {
|
||||
private readonly data =
|
||||
injectContext<TuiDialogContext<void, { content: Observable<string> }>>({
|
||||
optional: true,
|
||||
})?.data || inject(ActivatedRoute).snapshot.data
|
||||
|
||||
readonly content = toSignal<string>(this.data['content'])
|
||||
readonly error = toSignal(
|
||||
this.data['content'].pipe(
|
||||
protected readonly data = injectContext<{ data: Observable<string> }>().data
|
||||
protected readonly content = toSignal<string>(this.data)
|
||||
protected readonly error = toSignal(
|
||||
this.data.pipe(
|
||||
ignoreElements(),
|
||||
catchError(e => of(getErrorMessage(e))),
|
||||
),
|
||||
|
||||
@@ -95,6 +95,9 @@ $wide-modal: 900px;
|
||||
--tw-color-zinc-800: 39 39 42;
|
||||
--tw-color-zinc-900: 24 24 27;
|
||||
--tw-color-zinc-950: 9 9 11;
|
||||
|
||||
--tui-font-text: 'Proxima Nova', system-ui;
|
||||
--tui-font-heading: 'Proxima Nova', system-ui;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -172,14 +175,6 @@ a {
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06rem;
|
||||
margin: 0rem 0 1rem 0;
|
||||
margin: 0 0 1rem 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
56
web/projects/start-tunnel/src/app/app.config.ts
Normal file
56
web/projects/start-tunnel/src/app/app.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { tuiDropdownOptionsProvider } from '@taiga-ui/core'
|
||||
import { provideEventPlugins } from '@taiga-ui/event-plugins'
|
||||
import { provideAnimations } from '@angular/platform-browser/animations'
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core'
|
||||
import { provideRouter, withRouterConfig } from '@angular/router'
|
||||
import { tuiDialogOptionsProvider } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
PATCH_CACHE,
|
||||
PatchDbSource,
|
||||
} from 'src/app/services/patch-db/patch-db-source'
|
||||
import { routes } from './app.routes'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
|
||||
import {
|
||||
provideHttpClient,
|
||||
withFetch,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideAnimations(),
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' })),
|
||||
provideEventPlugins(),
|
||||
tuiDropdownOptionsProvider({ appearance: 'start-9' }),
|
||||
tuiDialogOptionsProvider({ appearance: 'start-9 taiga' }),
|
||||
{
|
||||
provide: PatchDB,
|
||||
deps: [PatchDbSource, PATCH_CACHE],
|
||||
useClass: PatchDB,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||
],
|
||||
}
|
||||
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
17
web/projects/start-tunnel/src/app/app.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { Routes } from '@angular/router'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () => import('./routes/home'),
|
||||
canMatch: [() => inject(AuthService).authenticated()],
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./routes/login'),
|
||||
canMatch: [() => !inject(AuthService).authenticated()],
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
]
|
||||
27
web/projects/start-tunnel/src/app/app.ts
Normal file
27
web/projects/start-tunnel/src/app/app.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { TuiRoot } from '@taiga-ui/core'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import { PatchService } from './services/patch.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, TuiRoot],
|
||||
template: '<tui-root><router-outlet /></tui-root>',
|
||||
styles: `
|
||||
:host {
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
border-image: none;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class App {
|
||||
readonly subscription = inject(PatchService)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
|
||||
@Component({
|
||||
selector: 'header',
|
||||
template: `
|
||||
<img alt="Start9" src="assets/icons/favicon.svg" />
|
||||
<h1>StartTunnel</h1>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.menu"
|
||||
appearance="action-grayscale"
|
||||
(click.stop)="sidebars.start.set(!sidebars.start())"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
background: var(--tui-background-neutral-2);
|
||||
box-shadow: var(--tui-shadow-medium);
|
||||
border-bottom: 1px solid var(--tui-border-normal);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font: var(--tui-font-heading-6);
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-column: span 1;
|
||||
|
||||
button {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton],
|
||||
})
|
||||
export class Header {
|
||||
protected readonly sidebars = inject(SidebarService)
|
||||
}
|
||||
132
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
132
web/projects/start-tunnel/src/app/routes/home/components/nav.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
|
||||
@Component({
|
||||
selector: 'nav',
|
||||
template: `
|
||||
<div>
|
||||
@for (route of routes; track $index) {
|
||||
<a
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
routerLinkActive="active"
|
||||
[iconStart]="route.icon"
|
||||
[routerLink]="route.link"
|
||||
>
|
||||
{{ route.name }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.log-out"
|
||||
appearance="neutral"
|
||||
size="s"
|
||||
(click)="logout()"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--tui-background-neutral-1);
|
||||
backdrop-filter: blur(1rem);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
transition: transform var(--tui-duration);
|
||||
}
|
||||
|
||||
div {
|
||||
flex: 1;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
margin: 0 0.5rem;
|
||||
|
||||
&.active {
|
||||
background: var(--tui-background-neutral-1);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
width: 14rem;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
|
||||
&:not(:focus-within, ._expanded) {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, RouterLink, RouterLinkActive],
|
||||
host: {
|
||||
'[class._expanded]': 'sidebars.start()',
|
||||
'(document:click)': 'sidebars.start.set(false)',
|
||||
'(mousedown.prevent)': '0',
|
||||
},
|
||||
})
|
||||
export class Nav {
|
||||
private readonly service = inject(AuthService)
|
||||
private readonly router = inject(Router)
|
||||
protected readonly sidebars = inject(SidebarService)
|
||||
protected readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly routes = [
|
||||
{
|
||||
name: 'Subnets',
|
||||
icon: '@tui.network',
|
||||
link: 'subnets',
|
||||
},
|
||||
{
|
||||
name: 'Devices',
|
||||
icon: '@tui.laptop',
|
||||
link: 'devices',
|
||||
},
|
||||
{
|
||||
name: 'Port Forwards',
|
||||
icon: '@tui.globe',
|
||||
link: 'port-forwards',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: '@tui.settings',
|
||||
link: 'settings',
|
||||
},
|
||||
] as const
|
||||
|
||||
protected async logout() {
|
||||
const loader = this.loader.open().subscribe()
|
||||
try {
|
||||
await this.api.logout()
|
||||
this.service.authenticated.set(false)
|
||||
this.router.navigate(['.'])
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { TuiScrollbar } from '@taiga-ui/core'
|
||||
import { Header } from 'src/app/routes/home/components/header'
|
||||
import { Nav } from 'src/app/routes/home/components/nav'
|
||||
|
||||
@Component({
|
||||
selector: 'app-outlet',
|
||||
template: `
|
||||
<header></header>
|
||||
<nav></nav>
|
||||
<main>
|
||||
<tui-scrollbar>
|
||||
<router-outlet />
|
||||
</tui-scrollbar>
|
||||
</main>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template: 3.5rem 1fr / 14rem 1fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template var(--tui-duration);
|
||||
}
|
||||
|
||||
main {
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
tui-scrollbar {
|
||||
max-width: 50rem;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--tui-radius-s);
|
||||
|
||||
::ng-deep > tui-scroll-controls {
|
||||
top: var(--tui-height-m);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template: 3.5rem 1fr / 1fr;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [Header, Nav, RouterOutlet, TuiScrollbar],
|
||||
})
|
||||
export class Outlet {}
|
||||
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
28
web/projects/start-tunnel/src/app/routes/home/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { Outlet } from 'src/app/routes/home/components/outlet'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
component: Outlet,
|
||||
children: [
|
||||
{
|
||||
path: 'subnets',
|
||||
loadComponent: () => import('./routes/subnets'),
|
||||
},
|
||||
{
|
||||
path: 'devices',
|
||||
loadComponent: () => import('./routes/devices'),
|
||||
},
|
||||
{
|
||||
path: 'port-forwards',
|
||||
loadComponent: () => import('./routes/port-forwards'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () => import('./routes/settings'),
|
||||
},
|
||||
{ path: '**', redirectTo: 'subnets' },
|
||||
],
|
||||
},
|
||||
] satisfies Routes
|
||||
@@ -0,0 +1,183 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Signal,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { utils } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TUI_IS_MOBILE,
|
||||
TuiAutoFocus,
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
} from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiElasticContainer,
|
||||
TuiFieldErrorPipe,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiForm } from '@taiga-ui/layout'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
import {
|
||||
getIp,
|
||||
DeviceData,
|
||||
MappedSubnet,
|
||||
subnetValidator,
|
||||
ipInSubnetValidator,
|
||||
} from './utils'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiForm [formGroup]="form">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Name</label>
|
||||
<input tuiTextfield tuiAutoFocus formControlName="name" />
|
||||
</tui-textfield>
|
||||
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
|
||||
|
||||
@if (!context.data.device) {
|
||||
<tui-textfield tuiChevron [stringify]="stringify">
|
||||
<label tuiLabel>Subnet</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
formControlName="subnet"
|
||||
placeholder="Select Subnet"
|
||||
[items]="context.data.subnets()"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect formControlName="subnet" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="context.data.subnets()"
|
||||
(itemClick)="onSubnet($event)"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="subnet"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
|
||||
<tui-elastic-container>
|
||||
@if (form.controls.subnet.value?.range) {
|
||||
<tui-textfield>
|
||||
<label tuiLabel>LAN IP</label>
|
||||
<input tuiTextfield tuiAutoFocus formControlName="ip" />
|
||||
</tui-textfield>
|
||||
}
|
||||
</tui-elastic-container>
|
||||
@if (form.controls.subnet.value?.range) {
|
||||
<tui-error
|
||||
formControlName="ip"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
}
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton (click)="onSave()">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiAutoFocus,
|
||||
TuiButton,
|
||||
TuiDataListWrapper,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiForm,
|
||||
TuiSelect,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiElasticContainer,
|
||||
],
|
||||
})
|
||||
export class DevicesAdd {
|
||||
private readonly loading = inject(LoadingService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
protected readonly context =
|
||||
injectContext<TuiDialogContext<void, DeviceData>>()
|
||||
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
name: [this.context.data.device?.name || '', Validators.required],
|
||||
subnet: [
|
||||
this.context.data.device?.subnet,
|
||||
[Validators.required, subnetValidator],
|
||||
],
|
||||
ip: [
|
||||
this.context.data.device?.ip || '',
|
||||
[Validators.required, Validators.pattern(utils.Patterns.ipv4.regex)],
|
||||
],
|
||||
})
|
||||
|
||||
protected readonly stringify = ({ range, name }: MappedSubnet) =>
|
||||
range ? `${name} (${range})` : ''
|
||||
|
||||
protected onSubnet(subnet: MappedSubnet) {
|
||||
this.form.controls.ip.clearValidators()
|
||||
this.form.controls.ip.addValidators([
|
||||
Validators.required,
|
||||
ipInSubnetValidator(subnet.range),
|
||||
])
|
||||
const ip = getIp(subnet)
|
||||
|
||||
if (ip) {
|
||||
this.form.controls.ip.setValue(ip)
|
||||
} else {
|
||||
this.form.controls.ip.disable()
|
||||
}
|
||||
|
||||
this.form.controls.subnet.markAsTouched()
|
||||
}
|
||||
|
||||
protected async onSave() {
|
||||
if (this.form.invalid) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const loader = this.loading.open().subscribe()
|
||||
const { ip, name, subnet } = this.form.getRawValue()
|
||||
const data = { ip, name, subnet: subnet?.range || '' }
|
||||
|
||||
try {
|
||||
this.context.data.device
|
||||
? await this.api.editDevice(data)
|
||||
: await this.api.addDevice(data)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DEVICES_ADD = new PolymorpheusComponent(DevicesAdd)
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCopy, TuiSegmented, TuiTextarea } from '@taiga-ui/kit'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { QrCodeComponent } from 'ng-qrcode'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>Device Config</h2>
|
||||
<aside tuiAccessories>
|
||||
<tui-segmented #segmented>
|
||||
<button>
|
||||
<tui-icon icon="@tui.file" />
|
||||
File
|
||||
</button>
|
||||
<button>
|
||||
<tui-icon icon="@tui.qr-code" />
|
||||
QR
|
||||
</button>
|
||||
</tui-segmented>
|
||||
</aside>
|
||||
</header>
|
||||
@if (segmented?.activeItemIndex) {
|
||||
<qr-code [value]="config" size="352" />
|
||||
} @else {
|
||||
<tui-textfield>
|
||||
<textarea
|
||||
tuiTextarea
|
||||
[min]="16"
|
||||
[max]="16"
|
||||
[readOnly]="true"
|
||||
[value]="config"
|
||||
></textarea>
|
||||
<tui-icon tuiCopy />
|
||||
<a
|
||||
tuiIconButton
|
||||
iconStart="@tui.download"
|
||||
download="start-tunnel.conf"
|
||||
size="s"
|
||||
[href]="href"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</tui-textfield>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
QrCodeComponent,
|
||||
TuiButton,
|
||||
TuiHeader,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
TuiSegmented,
|
||||
TuiTextfield,
|
||||
TuiTextarea,
|
||||
TuiCopy,
|
||||
],
|
||||
})
|
||||
export class DevicesConfig {
|
||||
protected readonly config =
|
||||
injectContext<TuiDialogContext<void, string>>().data
|
||||
protected readonly href = `data:text/plain;charset=utf-8,${encodeURIComponent(this.config)}`
|
||||
}
|
||||
|
||||
export const DEVICES_CONFIG = new PolymorpheusComponent(DevicesConfig)
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
Signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDialogService } from '@taiga-ui/experimental'
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { TunnelData } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
import { DEVICES_ADD } from './add'
|
||||
import { DEVICES_CONFIG } from './config'
|
||||
import { MappedDevice, MappedSubnet } from './utils'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Subnet</th>
|
||||
<th>LAN IP</th>
|
||||
<th [style.padding-inline-end.rem]="0.625">
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||
Add
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (device of devices(); track $index) {
|
||||
<tr>
|
||||
<td>{{ device.name }}</td>
|
||||
<td>{{ device.subnet.name }}</td>
|
||||
<td>{{ device.ip }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
tuiDropdown
|
||||
tuiDropdownOpen
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
>
|
||||
Actions
|
||||
<tui-data-list *tuiTextfieldDropdown size="s">
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.pencil"
|
||||
new
|
||||
(click)="onEdit(device)"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.settings"
|
||||
new
|
||||
(click)="onConfig(device)"
|
||||
>
|
||||
View Config
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.trash"
|
||||
new
|
||||
(click)="onDelete(device)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
|
||||
})
|
||||
export default class Devices {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loading = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly subnets: Signal<readonly MappedSubnet[]> = toSignal(
|
||||
inject<PatchDB<TunnelData>>(PatchDB)
|
||||
.watch$('wg', 'subnets')
|
||||
.pipe(
|
||||
map(subnets =>
|
||||
Object.entries(subnets).map(([range, { name, clients }]) => ({
|
||||
range,
|
||||
name,
|
||||
clients,
|
||||
})),
|
||||
),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
protected readonly devices = computed(() =>
|
||||
this.subnets().flatMap(subnet =>
|
||||
Object.entries(subnet.clients).map(([ip, { name }]) => ({
|
||||
subnet: {
|
||||
name: subnet.name,
|
||||
range: subnet.range,
|
||||
},
|
||||
ip,
|
||||
name,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
protected onAdd() {
|
||||
this.dialogs
|
||||
.open(DEVICES_ADD, {
|
||||
label: 'Add device',
|
||||
data: { subnets: this.subnets },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
protected onEdit(device: MappedDevice) {
|
||||
this.dialogs
|
||||
.open(DEVICES_ADD, {
|
||||
label: 'Rename device',
|
||||
data: { device, subnets: this.subnets },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
async onConfig({ subnet, ip }: MappedDevice) {
|
||||
const loader = this.loading.open().subscribe()
|
||||
try {
|
||||
const data = await this.api.showDeviceConfig({ subnet: subnet.range, ip })
|
||||
|
||||
this.dialogs.open(DEVICES_CONFIG, { data }).subscribe()
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
protected onDelete({ subnet, ip }: MappedDevice): void {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loading.open().subscribe()
|
||||
try {
|
||||
await this.api.deleteDevice({ subnet: subnet.range, ip })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
console.log(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Signal } from '@angular/core'
|
||||
import { AbstractControl } from '@angular/forms'
|
||||
import { utils } from '@start9labs/start-sdk'
|
||||
import { IpNet } from '@start9labs/start-sdk/util'
|
||||
import { WgServer } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
export interface MappedDevice {
|
||||
readonly subnet: {
|
||||
readonly name: string
|
||||
readonly range: string
|
||||
}
|
||||
readonly ip: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export interface MappedSubnet {
|
||||
readonly range: string
|
||||
readonly name: string
|
||||
readonly clients: WgServer['subnets']['']['clients']
|
||||
}
|
||||
|
||||
export interface DeviceData {
|
||||
readonly subnets: Signal<readonly MappedSubnet[]>
|
||||
readonly device?: MappedDevice
|
||||
}
|
||||
|
||||
export function subnetValidator({ value }: AbstractControl<MappedSubnet>) {
|
||||
return !value?.clients || getIp(value)
|
||||
? null
|
||||
: { noHosts: 'No hosts available' }
|
||||
}
|
||||
|
||||
export const ipInSubnetValidator = (subnet: string | null = null) => {
|
||||
const ipnet = subnet && utils.IpNet.parse(subnet)
|
||||
return ({ value }: AbstractControl<string>) => {
|
||||
let ip: utils.IpAddress
|
||||
try {
|
||||
ip = utils.IpAddress.parse(value)
|
||||
} catch (e) {
|
||||
return { invalidIp: 'Not a valid IP Address' }
|
||||
}
|
||||
if (!ipnet) return null
|
||||
const zero = ipnet.zero().cmp(ip)
|
||||
const broadcast = ipnet.broadcast().cmp(ip)
|
||||
return zero + broadcast === 0
|
||||
? null
|
||||
: zero === 0
|
||||
? { isZeroAddr: `Address cannot be the zero address` }
|
||||
: broadcast === 0
|
||||
? { isBroadcastAddress: `Address cannot be the broadcast address` }
|
||||
: { notInSubnet: `Address is not part of ${subnet}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function getIp({ clients, range }: MappedSubnet) {
|
||||
const net = IpNet.parse(range)
|
||||
const last = net.broadcast()
|
||||
|
||||
for (let ip = net.add(1); ip.cmp(last) === -1; ip.add(1)) {
|
||||
if (!clients[ip.address]) {
|
||||
return ip.address
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
TUI_IS_MOBILE,
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
} from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiNumberFormat,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiFieldErrorPipe,
|
||||
TuiInputNumber,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiForm } from '@taiga-ui/layout'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
import { MappedDevice, PortForwardsData } from './utils'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiForm [formGroup]="form">
|
||||
<tui-textfield tuiChevron>
|
||||
<label tuiLabel>External IP</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
formControlName="externalip"
|
||||
placeholder="Select IP"
|
||||
[items]="context.data.ips()"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect formControlName="externalip" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="context.data.ips()"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="externalip"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>External Port</label>
|
||||
<input
|
||||
tuiInputNumber
|
||||
formControlName="externalport"
|
||||
[min]="0"
|
||||
[max]="65535"
|
||||
[tuiNumberFormat]="{ thousandSeparator: '' }"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="externalport"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield tuiChevron [stringify]="stringify">
|
||||
<label tuiLabel>Device</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
formControlName="device"
|
||||
placeholder="Select Device"
|
||||
[items]="context.data.devices()"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect formControlName="device" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="context.data.devices()"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="device"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Internal Port</label>
|
||||
<input
|
||||
tuiInputNumber
|
||||
formControlName="internalport"
|
||||
[min]="0"
|
||||
[max]="65535"
|
||||
[tuiNumberFormat]="{ thousandSeparator: '' }"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="internalport"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<footer>
|
||||
<button tuiButton [disabled]="form.invalid" (click)="onSave()">
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiError,
|
||||
TuiInputNumber,
|
||||
TuiNumberFormat,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiSelect,
|
||||
TuiForm,
|
||||
],
|
||||
})
|
||||
export class PortForwardsAdd {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loading = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
protected readonly context =
|
||||
injectContext<TuiDialogContext<void, PortForwardsData>>()
|
||||
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
externalip: ['', Validators.required],
|
||||
externalport: [null as number | null, Validators.required],
|
||||
device: [null as MappedDevice | null, Validators.required],
|
||||
internalport: [null as number | null, Validators.required],
|
||||
})
|
||||
|
||||
protected readonly stringify = ({ ip, name }: MappedDevice) =>
|
||||
ip ? `${name} (${ip})` : ''
|
||||
|
||||
protected async onSave() {
|
||||
if (this.form.invalid) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const loader = this.loading.open().subscribe()
|
||||
const { externalip, externalport, device, internalport } =
|
||||
this.form.getRawValue()
|
||||
|
||||
try {
|
||||
await this.api.addForward({
|
||||
source: `${externalip}:${externalport}`,
|
||||
target: `${device?.ip}:${internalport}`,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PORT_FORWARDS_ADD = new PolymorpheusComponent(PortForwardsAdd)
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
Signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { utils } from '@start9labs/start-sdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiDialogService } from '@taiga-ui/experimental'
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { PORT_FORWARDS_ADD } from 'src/app/routes/home/routes/port-forwards/add'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { TunnelData } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
import { MappedDevice, MappedForward } from './utils'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>External IP</th>
|
||||
<th>External Port</th>
|
||||
<th>Device</th>
|
||||
<th>Internal Port</th>
|
||||
<th [style.padding-inline-end.rem]="0.625">
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||
Add
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (forward of forwards(); track $index) {
|
||||
<tr>
|
||||
<td>{{ forward.externalip }}</td>
|
||||
<td>{{ forward.externalport }}</td>
|
||||
<td>{{ forward.device.name }}</td>
|
||||
<td>{{ forward.internalport }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.trash"
|
||||
(click)="onDelete(forward)"
|
||||
>
|
||||
Actions
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, TuiButton],
|
||||
})
|
||||
export default class PortForwards {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loading = inject(LoadingService)
|
||||
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
private readonly portForwards = toSignal(this.patch.watch$('portForwards'))
|
||||
private readonly ips = toSignal(
|
||||
this.patch.watch$('gateways').pipe(
|
||||
map(g =>
|
||||
Object.values(g)
|
||||
.flatMap(
|
||||
val => val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) || [],
|
||||
)
|
||||
.filter(s => s.isIpv4() && s.isPublic())
|
||||
.map(s => s.address),
|
||||
),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
private readonly devices: Signal<MappedDevice[]> = toSignal(
|
||||
this.patch
|
||||
.watch$('wg', 'subnets')
|
||||
.pipe(
|
||||
map(subnets =>
|
||||
Object.values(subnets).flatMap(({ clients }) =>
|
||||
Object.entries(clients).map(([ip, { name }]) => ({ ip, name })),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
protected readonly forwards = computed(() =>
|
||||
Object.entries(this.portForwards() || {}).map(([source, target]) => {
|
||||
const sourceSplit = source.split(':')
|
||||
const targetSplit = target.split(':')
|
||||
|
||||
return {
|
||||
externalip: sourceSplit[0]!,
|
||||
externalport: sourceSplit[1]!,
|
||||
device: this.devices().find(d => d.ip === targetSplit[0])!,
|
||||
internalport: targetSplit[1]!,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
protected onAdd(): void {
|
||||
this.dialogs
|
||||
.open(PORT_FORWARDS_ADD, {
|
||||
label: 'Add port forward',
|
||||
data: { ips: this.ips, devices: this.devices },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
protected onDelete({ externalip, externalport }: MappedForward): void {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loading.open().subscribe()
|
||||
const source = `${externalip}:${externalport}`
|
||||
|
||||
try {
|
||||
await this.api.deleteForward({ source })
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Signal } from '@angular/core'
|
||||
|
||||
export interface MappedDevice {
|
||||
readonly ip: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export interface MappedForward {
|
||||
readonly externalip: string
|
||||
readonly externalport: string
|
||||
readonly device: MappedDevice
|
||||
readonly internalport: string
|
||||
}
|
||||
|
||||
export interface PortForwardsData {
|
||||
readonly ips: Signal<any>
|
||||
readonly devices: Signal<readonly MappedDevice[]>
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { DialogService, ErrorService } from '@start9labs/shared'
|
||||
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiFieldErrorPipe,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
|
||||
import { map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiCardLarge tuiAppearance="neutral" tuiForm [formGroup]="form">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
Settings
|
||||
<span tuiSubtitle>Change password</span>
|
||||
</h2>
|
||||
</header>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>New password</label>
|
||||
<input formControlName="password" tuiTextfield />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Confirm new password</label>
|
||||
<input
|
||||
formControlName="confirm"
|
||||
tuiTextfield
|
||||
[tuiValidator]="matchValidator()"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<footer>
|
||||
<button tuiButton (click)="onSave()" [loading]="loading()">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'This field is required',
|
||||
minlength: 'Password must be at least 8 characters',
|
||||
maxlength: 'Password cannot exceed 64 characters',
|
||||
match: 'Passwords do not match',
|
||||
}),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
AsyncPipe,
|
||||
TuiCard,
|
||||
TuiForm,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
TuiTextfield,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiValidator,
|
||||
TuiAppearance,
|
||||
],
|
||||
})
|
||||
export default class Settings {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly loading = signal(false)
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
password: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
confirm: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
})
|
||||
|
||||
protected readonly matchValidator = toSignal(
|
||||
this.form.controls.password.valueChanges.pipe(
|
||||
map(
|
||||
(password): ValidatorFn =>
|
||||
({ value }) =>
|
||||
value === password ? null : { match: true },
|
||||
),
|
||||
),
|
||||
{ initialValue: Validators.nullValidator },
|
||||
)
|
||||
|
||||
protected async onSave() {
|
||||
if (this.form.invalid) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.loading.set(true)
|
||||
|
||||
try {
|
||||
await this.api.setPassword({ password: this.form.getRawValue().password })
|
||||
this.alerts
|
||||
.open('Password changed', { label: 'Success', appearance: 'positive' })
|
||||
.subscribe()
|
||||
this.form.reset()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus, tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiFieldErrorPipe } from '@taiga-ui/kit'
|
||||
import { TuiForm } from '@taiga-ui/layout'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiForm [formGroup]="form">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Name</label>
|
||||
<input tuiTextfield tuiAutoFocus formControlName="name" />
|
||||
</tui-textfield>
|
||||
<tui-error formControlName="name" [error]="[] | tuiFieldError | async" />
|
||||
@if (!context.data.name) {
|
||||
<tui-textfield>
|
||||
<label tuiLabel>IP Range</label>
|
||||
<input tuiTextfield formControlName="subnet" />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="subnet"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton (click)="onSave()">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiAutoFocus,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiForm,
|
||||
TuiTextfield,
|
||||
],
|
||||
})
|
||||
export class SubnetsAdd {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loading = inject(LoadingService)
|
||||
|
||||
protected readonly context = injectContext<TuiDialogContext<void, Data>>()
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
name: [this.context.data.name, Validators.required],
|
||||
subnet: [
|
||||
this.context.data.subnet,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(
|
||||
'^(?:(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)/(?:[0-9]|1\\d|2[0-4])$',
|
||||
),
|
||||
],
|
||||
],
|
||||
})
|
||||
|
||||
protected async onSave() {
|
||||
if (this.form.invalid) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const loader = this.loading.open().subscribe()
|
||||
const value = this.form.getRawValue()
|
||||
|
||||
try {
|
||||
this.context.data.name
|
||||
? await this.api.editSubnet(value)
|
||||
: await this.api.addSubnet(value)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SUBNETS_ADD = new PolymorpheusComponent(SubnetsAdd)
|
||||
|
||||
interface Data {
|
||||
name: string
|
||||
subnet: string
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { utils } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDialogService } from '@taiga-ui/experimental'
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { TunnelData } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
import { SUBNETS_ADD } from './add'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Range</th>
|
||||
<th [style.padding-inline-end.rem]="0.625">
|
||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="onAdd()">
|
||||
Add
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (subnet of subnets(); track $index) {
|
||||
<tr>
|
||||
<td>{{ subnet.name }}</td>
|
||||
<td>{{ subnet.range }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
tuiDropdown
|
||||
tuiDropdownOpen
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
>
|
||||
Actions
|
||||
<tui-data-list *tuiTextfieldDropdown size="s">
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.pencil"
|
||||
new
|
||||
(click)="onEdit(subnet)"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.trash"
|
||||
new
|
||||
(click)="onDelete($index)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiDropdown, TuiDataList, TuiTextfield],
|
||||
})
|
||||
export default class Subnets {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loading = inject(LoadingService)
|
||||
|
||||
protected readonly subnets = toSignal<MappedSubnet[], []>(
|
||||
inject<PatchDB<TunnelData>>(PatchDB)
|
||||
.watch$('wg', 'subnets')
|
||||
.pipe(
|
||||
map(s =>
|
||||
Object.entries(s).map(([range, info]) => ({
|
||||
range,
|
||||
name: info.name,
|
||||
hasClients: !!Object.keys(info.clients).length,
|
||||
})),
|
||||
),
|
||||
),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
protected onAdd(): void {
|
||||
this.dialogs
|
||||
.open(SUBNETS_ADD, {
|
||||
label: 'Add Subnet',
|
||||
data: { subnet: this.getNext() },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
protected onEdit({ range, name }: MappedSubnet): void {
|
||||
this.dialogs
|
||||
.open(SUBNETS_ADD, {
|
||||
label: 'Rename Subnet',
|
||||
data: { subnet: range, name },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
protected onDelete(index: number): void {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, { label: 'Are you sure?' })
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const subnet = this.subnets()[index]?.range || ''
|
||||
const loader = this.loading.open().subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteSubnet({ subnet })
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getNext(): string {
|
||||
const last =
|
||||
this.subnets()
|
||||
.map(s => utils.IpNet.parse(s.range))
|
||||
.sort((a, b) => -1 * a.cmp(b))[0] ?? utils.IpNet.parse('10.58.255.0/24')
|
||||
const next = utils.IpNet.fromIpPrefix(last.broadcast().add(2), 24)
|
||||
if (!next.isPublic()) {
|
||||
return next.ipnet
|
||||
}
|
||||
|
||||
// No recommendation if /24 subnets are used
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
type MappedSubnet = {
|
||||
range: string
|
||||
name: string
|
||||
hasClients: boolean
|
||||
}
|
||||
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
86
web/projects/start-tunnel/src/app/routes/login/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { TuiButton, TuiError, TuiTextfield } from '@taiga-ui/core'
|
||||
import { TuiButtonLoading } from '@taiga-ui/kit'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<img alt="Start9" src="assets/icons/favicon.svg" />
|
||||
<form (ngSubmit)="login()">
|
||||
<tui-textfield [tuiTextfieldCleaner]="false">
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="password"
|
||||
(ngModelChange)="error.set(false)"
|
||||
[disabled]="loading()"
|
||||
/>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="action"
|
||||
iconStart="@tui.log-in"
|
||||
[loading]="loading()"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</tui-textfield>
|
||||
@if (error()) {
|
||||
<tui-error error="Password is invalid" />
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
tui-textfield {
|
||||
width: 18rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiTextfield, FormsModule, TuiError, TuiButtonLoading],
|
||||
})
|
||||
export default class Login {
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
protected readonly error = signal(false)
|
||||
protected readonly loading = signal(false)
|
||||
|
||||
password = ''
|
||||
|
||||
protected async login() {
|
||||
this.loading.set(true)
|
||||
try {
|
||||
await this.api.login({ password: this.password })
|
||||
this.auth.authenticated.set(true)
|
||||
this.router.navigate(['.'])
|
||||
} catch (e) {
|
||||
this.error.set(true)
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Dump } from 'patch-db-client'
|
||||
import { TunnelData } from '../patch-db/data-model'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export abstract class ApiService {
|
||||
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||
abstract subscribe(): Promise<SubscribeRes> // db.subscribe
|
||||
// auth
|
||||
abstract login(params: LoginReq): Promise<null> // auth.login
|
||||
abstract logout(): Promise<null> // auth.logout
|
||||
abstract setPassword(params: LoginReq): Promise<null> // auth.set-password
|
||||
// subnets
|
||||
abstract addSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
|
||||
abstract editSubnet(params: UpsertSubnetReq): Promise<null> // subnet.add
|
||||
abstract deleteSubnet(params: DeleteSubnetReq): Promise<null> // subnet.remove
|
||||
// devices
|
||||
abstract addDevice(params: UpsertDeviceReq): Promise<null> // device.add
|
||||
abstract editDevice(params: UpsertDeviceReq): Promise<null> // device.add
|
||||
abstract deleteDevice(params: DeleteDeviceReq): Promise<null> // device.remove
|
||||
abstract showDeviceConfig(params: DeleteDeviceReq): Promise<string> // device.show-config
|
||||
// forwards
|
||||
abstract addForward(params: AddForwardReq): Promise<null> // port-forward.add
|
||||
abstract deleteForward(params: DeleteForwardReq): Promise<null> // port-forward.remove
|
||||
}
|
||||
|
||||
export type SubscribeRes = {
|
||||
dump: Dump<TunnelData>
|
||||
guid: string
|
||||
}
|
||||
|
||||
export type LoginReq = { password: string }
|
||||
|
||||
export type UpsertSubnetReq = {
|
||||
name: string
|
||||
subnet: string
|
||||
}
|
||||
|
||||
export type DeleteSubnetReq = {
|
||||
subnet: string
|
||||
}
|
||||
|
||||
export type UpsertDeviceReq = {
|
||||
name: string
|
||||
subnet: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
export type DeleteDeviceReq = {
|
||||
subnet: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
export type AddForwardReq = {
|
||||
source: string // externalip:port
|
||||
target: string // internalip:port
|
||||
}
|
||||
|
||||
export type DeleteForwardReq = {
|
||||
source: string
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Injectable, DOCUMENT, inject } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
RPCOptions,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
ErrorService,
|
||||
} from '@start9labs/shared'
|
||||
import { filter, firstValueFrom, Observable } from 'rxjs'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import {
|
||||
AddForwardReq,
|
||||
ApiService,
|
||||
DeleteDeviceReq,
|
||||
DeleteForwardReq,
|
||||
DeleteSubnetReq,
|
||||
LoginReq,
|
||||
SubscribeRes,
|
||||
UpsertDeviceReq,
|
||||
UpsertSubnetReq,
|
||||
} from './api.service'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { PATCH_CACHE } from '../patch-db/patch-db-source'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
private readonly http = inject(HttpService)
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly cache$ = inject(PATCH_CACHE)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
openWebsocket$<T>(guid: string): Observable<T> {
|
||||
const { location } = this.document.defaultView!
|
||||
const host = location.host
|
||||
|
||||
return webSocket({
|
||||
url: `wss://${host}/ws/rpc/${guid}`,
|
||||
})
|
||||
}
|
||||
|
||||
async subscribe(): Promise<SubscribeRes> {
|
||||
return this.rpcRequest({ method: 'db.subscribe', params: {} })
|
||||
}
|
||||
|
||||
// auth
|
||||
|
||||
async login(params: LoginReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'auth.login', params })
|
||||
}
|
||||
|
||||
async logout(): Promise<null> {
|
||||
return this.rpcRequest({ method: 'auth.logout', params: {} })
|
||||
}
|
||||
|
||||
async setPassword(params: LoginReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'auth.set-password', params })
|
||||
}
|
||||
|
||||
async addSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||
return this.upsertSubnet(params)
|
||||
}
|
||||
|
||||
async editSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||
return this.upsertSubnet(params)
|
||||
}
|
||||
|
||||
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'subnet.remove', params })
|
||||
}
|
||||
|
||||
// devices
|
||||
|
||||
async addDevice(params: UpsertDeviceReq): Promise<null> {
|
||||
return this.upsertDevice(params)
|
||||
}
|
||||
|
||||
async editDevice(params: UpsertDeviceReq): Promise<null> {
|
||||
return this.upsertDevice(params)
|
||||
}
|
||||
|
||||
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'device.remove', params })
|
||||
}
|
||||
|
||||
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
|
||||
return this.rpcRequest({ method: 'device.show-config', params })
|
||||
}
|
||||
|
||||
// forwards
|
||||
|
||||
async addForward(params: AddForwardReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'port-forward.add', params })
|
||||
}
|
||||
|
||||
async deleteForward(params: DeleteForwardReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'port-forward.remove', params })
|
||||
}
|
||||
|
||||
// private
|
||||
|
||||
private async upsertSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'subnet.add', params })
|
||||
}
|
||||
|
||||
private async upsertDevice(params: UpsertDeviceReq): Promise<null> {
|
||||
return this.rpcRequest({ method: 'device.add', params })
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(
|
||||
options: RPCOptions,
|
||||
urlOverride?: string,
|
||||
): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(options, urlOverride)
|
||||
const body = res.body
|
||||
|
||||
if (isRpcError(body)) {
|
||||
if (body.error.code === 34) {
|
||||
console.error('Unauthenticated, logging out')
|
||||
this.auth.authenticated.set(false)
|
||||
}
|
||||
throw new RpcError(body.error)
|
||||
}
|
||||
|
||||
const patchSequence = res.headers.get('x-patch-sequence')
|
||||
if (patchSequence)
|
||||
await firstValueFrom(
|
||||
this.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
|
||||
)
|
||||
|
||||
return body.result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { shareReplay, Subject, tap } from 'rxjs'
|
||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||
import {
|
||||
AddForwardReq,
|
||||
ApiService,
|
||||
DeleteDeviceReq,
|
||||
DeleteForwardReq,
|
||||
DeleteSubnetReq,
|
||||
LoginReq,
|
||||
SubscribeRes,
|
||||
UpsertDeviceReq,
|
||||
UpsertSubnetReq,
|
||||
} from './api.service'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { AuthService } from '../auth.service'
|
||||
import {
|
||||
AddOperation,
|
||||
Operation,
|
||||
PatchOp,
|
||||
RemoveOperation,
|
||||
ReplaceOperation,
|
||||
Revision,
|
||||
} from 'patch-db-client'
|
||||
import { toObservable } from '@angular/core/rxjs-interop'
|
||||
import { mockTunnelData, WgClient, WgSubnet } from '../patch-db/data-model'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
private readonly auth = inject(AuthService)
|
||||
readonly mockWsSource$ = new Subject<Revision>()
|
||||
sequence = 1
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
toObservable(this.auth.authenticated)
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.sequence = 1
|
||||
}),
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
openWebsocket$<T>(guid: string): WebSocketSubject<T> {
|
||||
return this.mockWsSource$.pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
) as WebSocketSubject<T>
|
||||
}
|
||||
|
||||
async subscribe(): Promise<SubscribeRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
dump: { id: 1, value: mockTunnelData },
|
||||
guid: 'patch-db-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async login(params: LoginReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async logout(): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async setPassword(params: LoginReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
return null
|
||||
}
|
||||
|
||||
async addSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: AddOperation<WgSubnet>[] = [
|
||||
{
|
||||
op: PatchOp.ADD,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}`,
|
||||
value: { name: params.name, clients: {} },
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async editSubnet(params: UpsertSubnetReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: ReplaceOperation<string>[] = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}/name`,
|
||||
value: params.name,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteSubnet(params: DeleteSubnetReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: RemoveOperation[] = [
|
||||
{
|
||||
op: PatchOp.REMOVE,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}`,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async addDevice(params: UpsertDeviceReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: AddOperation<WgClient>[] = [
|
||||
{
|
||||
op: PatchOp.ADD,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`,
|
||||
value: { name: params.name },
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async editDevice(params: UpsertDeviceReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: ReplaceOperation<string>[] = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}/name`,
|
||||
value: params.name,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteDevice(params: DeleteDeviceReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: RemoveOperation[] = [
|
||||
{
|
||||
op: PatchOp.REMOVE,
|
||||
path: `/wg/subnets/${replaceSlashes(params.subnet)}/clients/${params.ip}`,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async showDeviceConfig(params: DeleteDeviceReq): Promise<string> {
|
||||
await pauseFor(1000)
|
||||
|
||||
return MOCK_CONFIG
|
||||
}
|
||||
|
||||
async addForward(params: AddForwardReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: AddOperation<string>[] = [
|
||||
{
|
||||
op: PatchOp.ADD,
|
||||
path: `/portForwards/${params.source}`,
|
||||
value: params.target,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteForward(params: DeleteForwardReq): Promise<null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch: RemoveOperation[] = [
|
||||
{
|
||||
op: PatchOp.REMOVE,
|
||||
path: `/portForwards/${params.source}`,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
|
||||
const revision = {
|
||||
id: ++this.sequence,
|
||||
patch,
|
||||
}
|
||||
this.mockWsSource$.next(revision)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSlashes(val: string) {
|
||||
return val.replace(new RegExp('/', 'g'), '~1')
|
||||
}
|
||||
|
||||
const MOCK_CONFIG = `[Interface]
|
||||
# Server's private IP address for the WireGuard VPN subnet
|
||||
Address = 10.20.10.1/24
|
||||
# UDP port WireGuard listens on
|
||||
ListenPort = 33333
|
||||
# Server private key (generated)
|
||||
PrivateKey = 4K68mdpQWdEz/FpdVuRoZYgWpQgpW63J9GFzn+iOulQ=
|
||||
|
||||
# Commands to run after starting/stopping WireGuard tunnel to enable forwarding and NAT (example)
|
||||
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
||||
|
||||
# Add client peers below with their public keys and allowed IPs
|
||||
[Peer]
|
||||
# Client public key
|
||||
PublicKey = MQBiYHxAj7u8paj3L4w4uav3P/9YBPbaN4gkWn90SSs=
|
||||
# Allowed client IP address within VPN subnet`
|
||||
20
web/projects/start-tunnel/src/app/services/auth.service.ts
Normal file
20
web/projects/start-tunnel/src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { effect, inject, Injectable, signal } from '@angular/core'
|
||||
import { WA_LOCAL_STORAGE } from '@ng-web-apis/common'
|
||||
|
||||
const KEY = '_startos/tunnel-loggedIn'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly storage = inject(WA_LOCAL_STORAGE)
|
||||
private readonly effect = effect(() => {
|
||||
if (this.authenticated()) {
|
||||
this.storage.setItem(KEY, JSON.stringify(true))
|
||||
} else {
|
||||
this.storage.removeItem(KEY)
|
||||
}
|
||||
})
|
||||
|
||||
readonly authenticated = signal(Boolean(this.storage.getItem(KEY)))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
export type TunnelData = {
|
||||
wg: WgServer
|
||||
portForwards: Record<string, string>
|
||||
gateways: Record<string, T.NetworkInterfaceInfo>
|
||||
}
|
||||
|
||||
export type WgServer = {
|
||||
subnets: Record<string, WgSubnet>
|
||||
}
|
||||
|
||||
export type WgSubnet = {
|
||||
name: string
|
||||
clients: Record<string, WgClient>
|
||||
}
|
||||
|
||||
export type WgClient = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const mockTunnelData: TunnelData = {
|
||||
wg: {
|
||||
subnets: {
|
||||
'10.59.0.0/24': {
|
||||
name: 'Family',
|
||||
clients: {
|
||||
'10.59.0.2': {
|
||||
name: 'Start9 Server',
|
||||
},
|
||||
'10.59.0.3': {
|
||||
name: 'Phone',
|
||||
},
|
||||
'10.59.0.4': {
|
||||
name: 'Laptop',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
portForwards: {
|
||||
'69.1.1.42:443': '10.59.0.2:5443',
|
||||
'69.1.1.42:3000': '10.59.0.2:3000',
|
||||
},
|
||||
gateways: {
|
||||
eth0: {
|
||||
name: null,
|
||||
public: null,
|
||||
secure: null,
|
||||
ipInfo: {
|
||||
name: 'Wired Connection 1',
|
||||
scopeId: 1,
|
||||
deviceType: 'ethernet',
|
||||
subnets: ['69.1.1.42/24'],
|
||||
wanIp: null,
|
||||
ntpServers: [],
|
||||
lanIp: ['10.59.0.1'],
|
||||
dnsServers: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core'
|
||||
import { Dump, Revision, Update } from 'patch-db-client'
|
||||
import { BehaviorSubject, EMPTY, Observable, timer } from 'rxjs'
|
||||
import {
|
||||
bufferTime,
|
||||
catchError,
|
||||
filter,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs/operators'
|
||||
import { ApiService } from '../api/api.service'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { TunnelData } from './data-model'
|
||||
import { toObservable } from '@angular/core/rxjs-interop'
|
||||
|
||||
export const PATCH_CACHE = new InjectionToken('', {
|
||||
factory: () =>
|
||||
new BehaviorSubject<Dump<TunnelData>>({
|
||||
id: 0,
|
||||
value: {} as TunnelData,
|
||||
}),
|
||||
})
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchDbSource extends Observable<Update<TunnelData>[]> {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly auth = inject(AuthService)
|
||||
|
||||
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
|
||||
switchMap(verified => (verified ? this.api.subscribe() : EMPTY)),
|
||||
switchMap(({ dump, guid }) =>
|
||||
this.api.openWebsocket$<Revision>(guid).pipe(
|
||||
bufferTime(250),
|
||||
filter(revisions => !!revisions.length),
|
||||
startWith([dump]),
|
||||
),
|
||||
),
|
||||
catchError((_, watch$) => timer(500).pipe(switchMap(() => watch$))),
|
||||
startWith([{ id: 0, value: {} as TunnelData }]),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
22
web/projects/start-tunnel/src/app/services/patch.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { tap, Observable } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { TunnelData } from './patch-db/data-model'
|
||||
import { AuthService } from './auth.service'
|
||||
import { toObservable } from '@angular/core/rxjs-interop'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchService extends Observable<unknown> {
|
||||
private readonly patch = inject<PatchDB<TunnelData>>(PatchDB)
|
||||
private readonly auth = inject(AuthService)
|
||||
|
||||
private readonly stream$ = toObservable(this.auth.authenticated).pipe(
|
||||
tap(authed => (authed ? this.patch.start() : this.patch.stop())),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable, signal } from '@angular/core'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SidebarService {
|
||||
readonly start = signal(false)
|
||||
}
|
||||
20
web/projects/start-tunnel/src/index.html
Normal file
20
web/projects/start-tunnel/src/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>StartTunnel</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/assets/icons/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
|
||||
</head>
|
||||
<body tuiTheme="dark">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
5
web/projects/start-tunnel/src/main.ts
Normal file
5
web/projects/start-tunnel/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser'
|
||||
import { appConfig } from './app/app.config'
|
||||
import { App } from './app/app'
|
||||
|
||||
bootstrapApplication(App, appConfig).catch(err => console.error(err))
|
||||
90
web/projects/start-tunnel/src/styles.scss
Normal file
90
web/projects/start-tunnel/src/styles.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
:root {
|
||||
height: 100%;
|
||||
|
||||
--tui-text-action: #428bf9;
|
||||
--tui-text-action-hover: #165eca;
|
||||
--tui-background-accent-1: #428bf9;
|
||||
--tui-background-accent-1-hover: #126df7;
|
||||
--tui-background-accent-1-pressed: #156ed4;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
conic-gradient(var(--tui-background-base)),
|
||||
radial-gradient(circle at top right, #5240a8, transparent 40%),
|
||||
radial-gradient(circle at bottom right, #9236c9, transparent),
|
||||
radial-gradient(circle at 25% 100%, #5b65d5, transparent 30%),
|
||||
radial-gradient(circle at bottom left, #0090c0, transparent 50%),
|
||||
radial-gradient(circle at top left, #2a5ba8, transparent 50%),
|
||||
linear-gradient(to bottom, #5654b2, transparent);
|
||||
background-blend-mode: hard-light;
|
||||
|
||||
&:not([tuiTheme]) {
|
||||
background-blend-mode: soft-light;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgb(255 255 255 / 15%);
|
||||
backdrop-filter: brightness(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tui-dropdown[data-appearance='start-9'] {
|
||||
background: none;
|
||||
backdrop-filter: blur(1rem);
|
||||
}
|
||||
|
||||
tui-dialog[new][data-appearance~='start-9'] {
|
||||
background: var(--tui-background-neutral-1);
|
||||
backdrop-filter: blur(5rem);
|
||||
}
|
||||
|
||||
.g-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: var(--tui-radius-s);
|
||||
background: var(--tui-background-neutral-1);
|
||||
box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1);
|
||||
|
||||
thead tr {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--tui-background-neutral-1);
|
||||
backdrop-filter: blur(5rem);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
backdrop-filter: brightness(0.9);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
height: var(--tui-height-m);
|
||||
padding: 0 1rem;
|
||||
text-align: start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: inset 0 1px var(--tui-background-neutral-1);
|
||||
|
||||
&:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qr-code {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
tui-data-list {
|
||||
--tui-text-action: var(--tui-text-primary);
|
||||
}
|
||||
9
web/projects/start-tunnel/tsconfig.json
Normal file
9
web/projects/start-tunnel/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core'
|
||||
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
CopyService,
|
||||
DialogService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
@@ -22,6 +27,14 @@ import { InterfaceComponent } from '../interface.component'
|
||||
selector: 'td[actions]',
|
||||
template: `
|
||||
<div class="desktop">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.info"
|
||||
(click)="viewDetails()"
|
||||
>
|
||||
{{ 'Address details' | i18n }}
|
||||
</button>
|
||||
@if (interface.value()?.type === 'ui') {
|
||||
<a
|
||||
tuiIconButton
|
||||
@@ -62,7 +75,7 @@ import { InterfaceComponent } from '../interface.component'
|
||||
>
|
||||
{{ 'Actions' | i18n }}
|
||||
<tui-data-list *tuiTextfieldDropdown="let close">
|
||||
<button tuiOption new iconStart="@tui.eye" (click)="onDetails.emit()">
|
||||
<button tuiOption new iconStart="@tui.info" (click)="viewDetails()">
|
||||
{{ 'Address details' | i18n }}
|
||||
</button>
|
||||
@if (interface.value()?.type === 'ui') {
|
||||
@@ -125,12 +138,24 @@ export class AddressActionsComponent {
|
||||
readonly interface = inject(InterfaceComponent)
|
||||
|
||||
readonly href = input.required<string>()
|
||||
readonly bullets = input.required<string[]>()
|
||||
readonly disabled = input.required<boolean>()
|
||||
|
||||
readonly onDetails = output<void>()
|
||||
|
||||
open = false
|
||||
|
||||
viewDetails() {
|
||||
this.dialog
|
||||
.openAlert(
|
||||
`<ul>${this.bullets()
|
||||
.map(b => `<li>${b}</li>`)
|
||||
.join('')}</ul>` as i18nKey,
|
||||
{
|
||||
label: 'About this address' as i18nKey,
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
showQR() {
|
||||
this.dialog
|
||||
.openComponent(new PolymorpheusComponent(QRModal), {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
selector: 'section[addresses]',
|
||||
template: `
|
||||
<header>{{ 'Addresses' | i18n }}</header>
|
||||
<table [appTable]="[null, 'Type', 'Access', 'Gateway', 'URL', null]">
|
||||
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
|
||||
@for (address of addresses()?.common; track $index) {
|
||||
<tr [address]="address" [isRunning]="isRunning()"></tr>
|
||||
} @empty {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { DisplayAddress } from '../interface.service'
|
||||
import { AddressActionsComponent } from './actions.component'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
@@ -14,16 +8,6 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
selector: 'tr[address]',
|
||||
template: `
|
||||
@if (address(); as address) {
|
||||
<td [style.width.rem]="3">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.info"
|
||||
(click)="viewDetails(address.bullets)"
|
||||
>
|
||||
{{ 'Address details' | i18n }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ address.type }}</td>
|
||||
<td>
|
||||
@if (address.access === 'public') {
|
||||
@@ -50,8 +34,8 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
actions
|
||||
[disabled]="!isRunning()"
|
||||
[href]="address.url"
|
||||
[bullets]="address.bullets"
|
||||
[style.width.rem]="5"
|
||||
(onDetails)="viewDetails(address.bullets)"
|
||||
></td>
|
||||
}
|
||||
`,
|
||||
@@ -89,22 +73,10 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [i18nPipe, AddressActionsComponent, TuiButton, TuiBadge],
|
||||
imports: [i18nPipe, AddressActionsComponent, TuiBadge],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceAddressItemComponent {
|
||||
readonly address = input.required<DisplayAddress>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
readonly dialog = inject(DialogService)
|
||||
|
||||
viewDetails(bullets: string[]) {
|
||||
this.dialog
|
||||
.openAlert(
|
||||
`<ul>${bullets.map(b => `<li>${b}</li>`).join('')}</ul>` as i18nKey,
|
||||
{
|
||||
label: 'About this address' as i18nKey,
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class InterfaceTorDomainsComponent {
|
||||
? await this.api.addTorKey({ key })
|
||||
: await this.api.generateTorKey({})
|
||||
|
||||
if (this.interface.packageId) {
|
||||
if (this.interface.packageId()) {
|
||||
await this.api.pkgAddOnion({
|
||||
onion,
|
||||
package: this.interface.packageId(),
|
||||
|
||||
@@ -182,16 +182,14 @@ export class MarketplacePreviewComponent {
|
||||
}
|
||||
|
||||
onStatic() {
|
||||
const content = this.pkg$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
|
||||
)
|
||||
|
||||
this.dialog
|
||||
.openComponent(MARKDOWN, {
|
||||
label: 'License',
|
||||
size: 'l',
|
||||
data: { content },
|
||||
data: this.pkg$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
|
||||
),
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class._new]': '!notificationItem.seen',
|
||||
'(click)': 'onClick()',
|
||||
},
|
||||
styles: `
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
signal,
|
||||
@@ -43,7 +45,9 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
[notificationItem]="notification"
|
||||
(longtap)="!selected().length && onToggle(notification)"
|
||||
(click.capture)="
|
||||
selected().length && onToggle(notification, $event)
|
||||
selected().length &&
|
||||
$any($event.target).closest('tui-root._mobile') &&
|
||||
onToggle(notification, $event)
|
||||
"
|
||||
>
|
||||
<input
|
||||
@@ -81,6 +85,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
top: 0.875rem;
|
||||
left: 1rem;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host:not(:has(:checked)) input {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
INJECTOR,
|
||||
} from '@angular/core'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
CopyService,
|
||||
@@ -11,12 +6,12 @@ import {
|
||||
getPkgId,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
MarkdownComponent,
|
||||
MARKDOWN,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { from, map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import {
|
||||
@@ -58,15 +53,17 @@ import {
|
||||
imports: [ServiceAdditionalItemComponent, TuiCell, i18nPipe],
|
||||
})
|
||||
export default class ServiceAboutRoute {
|
||||
private readonly pkgId = getPkgId()
|
||||
private readonly copyService = inject(CopyService)
|
||||
private readonly markdown = inject(DialogService).openComponent(
|
||||
new PolymorpheusComponent(MarkdownComponent, inject(INJECTOR)),
|
||||
{ label: 'License', size: 'l' },
|
||||
)
|
||||
private readonly markdown = inject(DialogService).openComponent(MARKDOWN, {
|
||||
label: 'License',
|
||||
size: 'l',
|
||||
data: from(inject(ApiService).getStaticInstalled(this.pkgId, 'LICENSE.md')),
|
||||
})
|
||||
|
||||
readonly groups = toSignal<{ header: i18nKey; items: AdditionalItem[] }[]>(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData', getPkgId())
|
||||
.watch$('packageData', this.pkgId)
|
||||
.pipe(
|
||||
map(pkg => {
|
||||
const manifest = getManifest(pkg)
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router'
|
||||
import { defer, map, Observable, of } from 'rxjs'
|
||||
import { share } from 'rxjs/operators'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { Routes } from '@angular/router'
|
||||
import { titleResolver } from 'src/app/utils/title-resolver'
|
||||
|
||||
import { ServiceOutletComponent } from './routes/outlet.component'
|
||||
@@ -33,7 +29,6 @@ export const ROUTES: Routes = [
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./routes/about.component'),
|
||||
resolve: { content: getStatic() },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -44,15 +39,4 @@ export const ROUTES: Routes = [
|
||||
},
|
||||
]
|
||||
|
||||
function getStatic(): ResolveFn<Observable<string>> {
|
||||
return ({ paramMap }: ActivatedRouteSnapshot) =>
|
||||
of(inject(ApiService)).pipe(
|
||||
map(api =>
|
||||
defer(() =>
|
||||
api.getStaticInstalled(paramMap.get('pkgId')!, 'LICENSE.md'),
|
||||
).pipe(share()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export default ROUTES
|
||||
|
||||
@@ -87,13 +87,11 @@ export class SideloadPackageComponent {
|
||||
readonly file = input.required<File>()
|
||||
|
||||
onStatic() {
|
||||
const content = of(this.pkg()['license'])
|
||||
|
||||
this.dialog
|
||||
.openComponent(MARKDOWN, {
|
||||
label: 'License',
|
||||
size: 'l',
|
||||
data: { content },
|
||||
data: of(this.pkg()['license']),
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
@for (session of sessions(); track $index) {
|
||||
<tr
|
||||
(longtap)="!selected().length && onToggle(session)"
|
||||
(click)="selected().length && onToggle(session)"
|
||||
(click)="
|
||||
selected().length &&
|
||||
$any($event.target).closest('tui-root._mobile') &&
|
||||
onToggle(session)
|
||||
"
|
||||
>
|
||||
<td [style.padding-left.rem]="single() ? null : 2.5">
|
||||
@if (!single()) {
|
||||
@@ -123,6 +127,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
|
||||
input {
|
||||
left: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
td {
|
||||
|
||||
@@ -31,7 +31,11 @@ import { SSHKey } from 'src/app/services/api/api.types'
|
||||
@for (key of keys(); track $index) {
|
||||
<tr
|
||||
(longtap)="!selected().length && onToggle(key)"
|
||||
(click)="selected().length && onToggle(key)"
|
||||
(click)="
|
||||
selected().length &&
|
||||
$any($event.target).closest('tui-root._mobile') &&
|
||||
onToggle(key)
|
||||
"
|
||||
>
|
||||
<td [style.padding-left.rem]="2.5">
|
||||
<input
|
||||
@@ -104,6 +108,7 @@ import { SSHKey } from 'src/app/services/api/api.types'
|
||||
|
||||
input {
|
||||
left: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
td {
|
||||
|
||||
@@ -149,6 +149,11 @@ import UpdatesComponent from './updates.component'
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
tui-progress-circle {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -172,12 +177,7 @@ import UpdatesComponent from './updates.component'
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
||||
div {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&[colspan]:only-child {
|
||||
|
||||
@@ -19,19 +19,17 @@ import {
|
||||
TuiSkeleton,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, tap } from 'rxjs'
|
||||
import { combineLatest, tap } from 'rxjs'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||
import { LocalPackagesService } from 'src/app/services/local-packages.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import {
|
||||
DataModel,
|
||||
InstalledState,
|
||||
PackageDataEntry,
|
||||
UpdatingState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { isInstalled, isUpdating } from 'src/app/utils/get-package-data'
|
||||
import { FilterUpdatesPipe } from './filter-updates.pipe'
|
||||
import { UpdatesItemComponent } from './item.component'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
@@ -256,21 +254,7 @@ export default class UpdatesComponent {
|
||||
),
|
||||
),
|
||||
marketplace: this.marketplaceService.marketplace$,
|
||||
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData')
|
||||
.pipe(
|
||||
map(pkgs =>
|
||||
Object.entries(pkgs).reduce<
|
||||
Record<string, PackageDataEntry<InstalledState | UpdatingState>>
|
||||
>(
|
||||
(acc, [id, val]) =>
|
||||
isInstalled(val) || isUpdating(val)
|
||||
? { ...acc, [id]: val }
|
||||
: acc,
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
localPkgs: inject(LocalPackagesService),
|
||||
errors: this.marketplaceService.requestErrors$,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -110,7 +110,7 @@ export namespace Mock {
|
||||
squashfs: {
|
||||
aarch64: {
|
||||
publishedAt: '2025-04-21T20:58:48.140749883Z',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_aarch64.squashfs',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_aarch64.squashfs',
|
||||
commitment: {
|
||||
hash: '4elBFVkd/r8hNadKmKtLIs42CoPltMvKe2z3LRqkphk=',
|
||||
size: 1343500288,
|
||||
@@ -122,7 +122,7 @@ export namespace Mock {
|
||||
},
|
||||
'aarch64-nonfree': {
|
||||
publishedAt: '2025-04-21T21:07:00.249285116Z',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_aarch64-nonfree.squashfs',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_aarch64-nonfree.squashfs',
|
||||
commitment: {
|
||||
hash: 'MrCEi4jxbmPS7zAiGk/JSKlMsiuKqQy6RbYOxlGHOIQ=',
|
||||
size: 1653075968,
|
||||
@@ -134,7 +134,7 @@ export namespace Mock {
|
||||
},
|
||||
raspberrypi: {
|
||||
publishedAt: '2025-04-21T21:16:12.933319237Z',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_raspberrypi.squashfs',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_raspberrypi.squashfs',
|
||||
commitment: {
|
||||
hash: '/XTVQRCqY3RK544PgitlKu7UplXjkmzWoXUh2E4HCw0=',
|
||||
size: 1490731008,
|
||||
@@ -146,7 +146,7 @@ export namespace Mock {
|
||||
},
|
||||
x86_64: {
|
||||
publishedAt: '2025-04-21T21:14:20.246908903Z',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_x86_64.squashfs',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_x86_64.squashfs',
|
||||
commitment: {
|
||||
hash: '/6romKTVQGSaOU7FqSZdw0kFyd7P+NBSYNwM3q7Fe44=',
|
||||
size: 1411657728,
|
||||
@@ -158,7 +158,7 @@ export namespace Mock {
|
||||
},
|
||||
'x86_64-nonfree': {
|
||||
publishedAt: '2025-04-21T21:15:17.955265284Z',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.11/startos-0.4.0-alpha.11-33ae46f~dev_x86_64-nonfree.squashfs',
|
||||
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.12/startos-0.4.0-alpha.12-33ae46f~dev_x86_64-nonfree.squashfs',
|
||||
commitment: {
|
||||
hash: 'HCRq9sr/0t85pMdrEgNBeM4x11zVKHszGnD1GDyZbSE=',
|
||||
size: 1731035136,
|
||||
@@ -385,7 +385,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -420,7 +420,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -465,7 +465,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -500,7 +500,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -547,7 +547,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release to 0.17.5',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -595,7 +595,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release to 0.17.4',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -647,7 +647,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -682,7 +682,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -727,7 +727,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release and minor fixes.',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -775,7 +775,7 @@ export namespace Mock {
|
||||
marketingSite: '',
|
||||
releaseNotes: 'Upstream release and minor fixes.',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.41',
|
||||
sdkVersion: '0.4.0-beta.42',
|
||||
gitHash: 'fakehash',
|
||||
icon: PROXY_ICON,
|
||||
sourceVersion: null,
|
||||
|
||||
@@ -794,10 +794,18 @@ export class LiveApiService extends ApiService {
|
||||
)
|
||||
return res.body
|
||||
}
|
||||
const computedDigest = Buffer.from(blake3(data)).toString('base64')
|
||||
if (`blake3=:${computedDigest}:` === digest) return res.body
|
||||
console.debug(computedDigest, digest)
|
||||
throw new Error('File digest mismatch.')
|
||||
const [alg, hash] = digest.split('=', 2)
|
||||
if (alg === 'blake3') {
|
||||
if (
|
||||
Buffer.from(blake3(data)).compare(
|
||||
Buffer.from(hash?.replace(/:/g, '') || '', 'base64'),
|
||||
) !== 0
|
||||
) {
|
||||
throw new Error('File digest mismatch.')
|
||||
}
|
||||
} else {
|
||||
console.warn(`Unknown Repr-Digest algorithm ${alg}`)
|
||||
}
|
||||
}
|
||||
return res.body
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export const mockPatchData: DataModel = {
|
||||
staticServers: null,
|
||||
},
|
||||
},
|
||||
unreadNotificationCount: 4,
|
||||
unreadNotificationCount: 5,
|
||||
// password is asdfasdf
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
filter,
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { OSService } from 'src/app/services/os.service'
|
||||
import { combineLatest, EMPTY, map, Observable, shareReplay } from 'rxjs'
|
||||
import { LocalPackagesService } from 'src/app/services/local-packages.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
import { OSService } from 'src/app/services/os.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import { FilterUpdatesPipe } from '../routes/portal/routes/updates/filter-updates.pipe'
|
||||
|
||||
@Injectable({
|
||||
@@ -35,32 +23,9 @@ export class BadgeService {
|
||||
private readonly marketplaceService = inject(MarketplaceService)
|
||||
private readonly filterUpdatesPipe = inject(FilterUpdatesPipe)
|
||||
|
||||
private readonly local$ = inject(ConnectionService).pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$('packageData').pipe(first())),
|
||||
switchMap(outer =>
|
||||
this.patch.watch$('packageData').pipe(
|
||||
pairwise(),
|
||||
filter(([prev, curr]) =>
|
||||
Object.values(prev).some(p => {
|
||||
const { id } = getManifest(p)
|
||||
|
||||
return (
|
||||
!curr[id] ||
|
||||
(p.stateInfo.installingInfo &&
|
||||
!curr[id]?.stateInfo.installingInfo)
|
||||
)
|
||||
}),
|
||||
),
|
||||
map(([_, curr]) => curr),
|
||||
startWith(outer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private readonly updates$ = combineLatest([
|
||||
this.marketplaceService.marketplace$,
|
||||
this.local$,
|
||||
inject(LocalPackagesService),
|
||||
]).pipe(
|
||||
map(
|
||||
([marketplace, local]) =>
|
||||
|
||||
@@ -24,6 +24,11 @@ export class GatewayService {
|
||||
map(gateways =>
|
||||
Object.entries(gateways)
|
||||
.filter(([_, val]) => !!val?.ipInfo)
|
||||
.filter(
|
||||
([_, val]) =>
|
||||
val?.ipInfo?.deviceType !== 'bridge' &&
|
||||
val?.ipInfo?.deviceType !== 'loopback',
|
||||
)
|
||||
.map(([id, val]) => {
|
||||
const subnets =
|
||||
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
|
||||
|
||||
34
web/projects/ui/src/app/services/local-packages.service.ts
Normal file
34
web/projects/ui/src/app/services/local-packages.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map, Observable, shareReplay } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
InstalledState,
|
||||
PackageDataEntry,
|
||||
UpdatingState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { isInstalled, isUpdating } from 'src/app/utils/get-package-data'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LocalPackagesService extends Observable<
|
||||
Record<string, PackageDataEntry<InstalledState | UpdatingState>>
|
||||
> {
|
||||
private readonly stream$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData')
|
||||
.pipe(
|
||||
map(pkgs =>
|
||||
Object.entries(pkgs).reduce(
|
||||
(acc, [id, val]) =>
|
||||
isInstalled(val) || isUpdating(val) ? { ...acc, [id]: val } : acc,
|
||||
{},
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -95,21 +95,26 @@ export class NotificationService {
|
||||
|
||||
viewModal(notification: ServerNotification<number>, full = false) {
|
||||
const { data, createdAt, code, title, message } = notification
|
||||
const label = code === 1 ? 'Backup Report' : (title as i18nKey)
|
||||
const component = code === 1 ? REPORT : MARKDOWN
|
||||
const content = code === 1 ? data : of(data)
|
||||
|
||||
this.markSeen([notification])
|
||||
this.dialogs
|
||||
.openComponent(full ? message : component, {
|
||||
label,
|
||||
data: {
|
||||
content,
|
||||
timestamp: createdAt,
|
||||
},
|
||||
size: code === 1 ? 'm' : 'l',
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
if (code === 1) {
|
||||
// Backup Report
|
||||
this.dialogs
|
||||
.openComponent(full ? message : REPORT, {
|
||||
label: 'Backup Report',
|
||||
data: { content: data, createdAt },
|
||||
})
|
||||
.subscribe()
|
||||
} else {
|
||||
// Markdown viewer
|
||||
this.dialogs
|
||||
.openComponent(full ? message : MARKDOWN, {
|
||||
label: title as i18nKey,
|
||||
data: of(data),
|
||||
size: 'l',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async updateCount(toAdjust: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { WA_LOCAL_STORAGE } from '@ng-web-apis/common'
|
||||
|
||||
const PREFIX = '_startos/'
|
||||
|
||||
@@ -6,9 +7,7 @@ const PREFIX = '_startos/'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StorageService {
|
||||
private readonly storage = this.document.defaultView!.localStorage
|
||||
|
||||
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||
private readonly storage = inject(WA_LOCAL_STORAGE)
|
||||
|
||||
get<T>(key: string): T {
|
||||
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))
|
||||
|
||||
@@ -25,7 +25,6 @@ hr {
|
||||
|
||||
:root {
|
||||
--bumper: 0.375rem;
|
||||
--tui-font-text: 'Proxima Nova', system-ui;
|
||||
}
|
||||
|
||||
.g-page {
|
||||
|
||||
Reference in New Issue
Block a user