Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Aiden McClelland
2023-11-13 14:59:16 -07:00
1115 changed files with 6871 additions and 1851 deletions

View File

@@ -0,0 +1,28 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
@Injectable()
export class DownloadHTMLService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
async download(filename: string, html: string, styleObj = {}) {
const entries = Object.entries(styleObj)
.map(([k, v]) => `${k}:${v}`)
.join(';')
const styleString = entries ? `<style>html{${entries}}></style>` : ''
html = styleString + html
const elem = this.document.createElement('a')
elem.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(html),
)
elem.setAttribute('download', filename)
elem.style.display = 'none'
this.document.body.appendChild(elem)
elem.click()
this.document.body.removeChild(elem)
}
}

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core'
import * as emver from '@start9labs/emver'
@Injectable({
providedIn: 'root',
})
export class Emver {
constructor() {}
compare(lhs: string, rhs: string): number | null {
if (!lhs || !rhs) return null
return emver.compare(lhs, rhs)
}
satisfies(version: string, range: string): boolean {
return emver.satisfies(version, range)
}
}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@angular/core'
import { IonicSafeString, ToastController } from '@ionic/angular'
import { HttpError } from '../classes/http-error'
@Injectable({
providedIn: 'root',
})
export class ErrorToastService {
private toast?: HTMLIonToastElement
constructor(private readonly toastCtrl: ToastController) {}
async present(e: HttpError | string, link?: string): Promise<void> {
console.error(e)
if (this.toast) return
this.toast = await this.toastCtrl.create({
header: 'Error',
message: getErrorMessage(e, link),
duration: 0,
position: 'top',
cssClass: 'error-toast',
buttons: [
{
side: 'end',
icon: 'close',
handler: () => {
this.dismiss()
},
},
],
})
await this.toast.present()
}
async dismiss(): Promise<void> {
if (this.toast) {
await this.toast.dismiss()
this.toast = undefined
}
}
}
export function getErrorMessage(
e: HttpError | string,
link?: string,
): string | IonicSafeString {
let message = ''
if (typeof e === 'string') {
message = e
} else if (e.code === 0) {
message =
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
link = 'https://docs.start9.com/0.3.5.x/support/common-issues#request-error'
} else if (!e.message) {
message = 'Unknown Error'
} else {
message = e.message
}
if (link) {
return new IonicSafeString(
`${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer" style="color: white;">Get Help</a>`,
)
}
return message
}

View File

@@ -0,0 +1,43 @@
import { ErrorHandler, inject, Injectable } from '@angular/core'
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
import { HttpError } from '../classes/http-error'
// TODO: Enable this as ErrorHandler
@Injectable({
providedIn: 'root',
})
export class ErrorService extends ErrorHandler {
private readonly alerts = inject(TuiAlertService)
override handleError(error: HttpError | string, link?: string) {
console.error(error)
this.alerts
.open(getErrorMessage(error, link), {
label: 'Error',
autoClose: false,
status: TuiNotification.Error,
})
.subscribe()
}
}
function getErrorMessage(e: HttpError | string, link?: string): string {
let message = ''
if (typeof e === 'string') {
message = e
} else if (e.code === 0) {
message =
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
} else if (!e.message) {
message = 'Unknown Error'
link = 'https://docs.start9.com/latest/support/faq'
} else {
message = e.message
}
return link
? `${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer">Get Help</a>`
: message
}

View File

@@ -0,0 +1,107 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import {
firstValueFrom,
from,
interval,
lastValueFrom,
map,
Observable,
race,
take,
} from 'rxjs'
import { HttpError } from '../classes/http-error'
import {
HttpAngularOptions,
HttpOptions,
LocalHttpResponse,
Method,
} from '../types/http.types'
import { RPCResponse, RPCOptions } from '../types/rpc.types'
import { RELATIVE_URL } from '../tokens/relative-url'
@Injectable({
providedIn: 'root',
})
export class HttpService {
private fullUrl: string
constructor(
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpClient,
) {
this.fullUrl = this.document.location.origin
}
async rpcRequest<T>(
opts: RPCOptions,
fullUrl?: string,
): Promise<LocalHttpResponse<RPCResponse<T>>> {
const { method, headers, params, timeout } = opts
return this.httpRequest<RPCResponse<T>>({
method: Method.POST,
url: fullUrl || this.relativeUrl,
headers,
body: { method, params },
timeout,
})
}
async httpRequest<T>(opts: HttpOptions): Promise<LocalHttpResponse<T>> {
let { method, url, headers, body, responseType, timeout } = opts
url = opts.url.startsWith('/') ? this.fullUrl + url : url
const { params } = opts
if (hasParams(params)) {
Object.keys(params).forEach(key => {
if (params[key] === undefined) {
delete params[key]
}
})
}
const options: HttpAngularOptions = {
observe: 'response',
withCredentials: true,
headers,
params,
responseType: responseType || 'json',
}
let req: Observable<LocalHttpResponse<T>>
if (method === Method.GET) {
req = this.http.get(url, options as any) as any
} else {
req = this.http.post(url, body, options as any) as any
}
return firstValueFrom(timeout ? withTimeout(req, timeout) : req).catch(
e => {
throw new HttpError(e)
},
)
}
}
function hasParams(
params?: HttpOptions['params'],
): params is Record<string, string | string[]> {
return !!params
}
function withTimeout<U>(req: Observable<U>, timeout: number): Observable<U> {
return race(
from(lastValueFrom(req)), // this guarantees it only emits on completion, intermediary emissions are suppressed.
interval(timeout).pipe(
take(1),
map(() => {
throw new Error('timeout')
}),
),
)
}

View File

@@ -0,0 +1,30 @@
import { StaticClassProvider } from '@angular/core'
import { defer, Observable, switchMap } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Log } from '../types/api'
import { Constructor } from '../types/constructor'
interface Api {
followLogs: () => Promise<string>
openLogsWebsocket$: (config: WebSocketSubjectConfig<Log>) => Observable<Log>
}
export function provideSetupLogsService(
api: Constructor<Api>,
): StaticClassProvider {
return {
provide: SetupLogsService,
deps: [api],
useClass: SetupLogsService,
}
}
export class SetupLogsService extends Observable<Log> {
private readonly log$ = defer(() => this.api.followLogs()).pipe(
switchMap(url => this.api.openLogsWebsocket$({ url })),
)
constructor(private readonly api: Api) {
super(subscriber => this.log$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,59 @@
import { inject, StaticClassProvider, Type } from '@angular/core'
import {
catchError,
EMPTY,
exhaustMap,
filter,
from,
interval,
map,
Observable,
shareReplay,
takeWhile,
} from 'rxjs'
import { SetupStatus } from '../types/api'
import { ErrorToastService } from './error-toast.service'
import { Constructor } from '../types/constructor'
export function provideSetupService(
api: Constructor<ConstructorParameters<typeof SetupService>[0]>,
): StaticClassProvider {
return {
provide: SetupService,
deps: [api],
useClass: SetupService,
}
}
export class SetupService extends Observable<number> {
private readonly errorToastService = inject(ErrorToastService)
private readonly progress$ = interval(500).pipe(
exhaustMap(() =>
from(this.api.getSetupStatus()).pipe(
catchError(e => {
this.errorToastService.present(e)
return EMPTY
}),
),
),
filter(Boolean),
map(progress => {
if (progress.complete) {
return 1
}
return progress['total-bytes']
? progress['bytes-transferred'] / progress['total-bytes']
: 0
}),
takeWhile(value => value !== 1, true),
shareReplay(1),
)
constructor(
private readonly api: { getSetupStatus: () => Promise<SetupStatus | null> },
) {
super(subscriber => this.progress$.subscribe(subscriber))
}
}