mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 04:53:40 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
28
web/projects/shared/src/services/download-html.service.ts
Normal file
28
web/projects/shared/src/services/download-html.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
18
web/projects/shared/src/services/emver.service.ts
Normal file
18
web/projects/shared/src/services/emver.service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
70
web/projects/shared/src/services/error-toast.service.ts
Normal file
70
web/projects/shared/src/services/error-toast.service.ts
Normal 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
|
||||
}
|
||||
43
web/projects/shared/src/services/error.service.ts
Normal file
43
web/projects/shared/src/services/error.service.ts
Normal 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
|
||||
}
|
||||
107
web/projects/shared/src/services/http.service.ts
Normal file
107
web/projects/shared/src/services/http.service.ts
Normal 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')
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
30
web/projects/shared/src/services/setup-logs.service.ts
Normal file
30
web/projects/shared/src/services/setup-logs.service.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
59
web/projects/shared/src/services/setup.service.ts
Normal file
59
web/projects/shared/src/services/setup.service.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user