[Feat] follow logs (#1714)

* tail logs

* add cli

* add FE

* abstract http to shared

* batch new logs

* file download for logs

* fix modal error when no config

Co-authored-by: Chris Guida <chrisguida@users.noreply.github.com>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: BluJ <mogulslayer@gmail.com>
This commit is contained in:
Chris Guida
2022-08-03 13:06:25 -05:00
committed by GitHub
parent c44eb3a2c3
commit 2f8d825970
70 changed files with 2202 additions and 1795 deletions

View File

@@ -1,4 +1,4 @@
<a class="logo ion-padding" target="_blank" rel="noreferrer" [href]="href">
<a class="logo ion-padding" routerLink="/services">
<img alt="Start9" src="assets/img/logo.png" />
</a>
<div class="divider"></div>

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { ConfigService } from '../../services/config.service'
import { LocalStorageService } from '../../services/local-storage.service'
import { EOSService } from '../../services/eos.service'
import { ApiService } from '../../services/api/embassy-api.service'
@@ -62,7 +61,6 @@ export class MenuComponent {
.pipe(map(pkgs => pkgs.length))
constructor(
private readonly config: ConfigService,
private readonly alertCtrl: AlertController,
private readonly embassyApi: ApiService,
private readonly authService: AuthService,
@@ -73,12 +71,6 @@ export class MenuComponent {
private readonly marketplaceService: MarketplaceService,
) {}
get href(): string {
return this.config.isTor()
? 'http://privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion'
: 'https://start9.com'
}
async presentAlertLogout() {
const alert = await this.alertCtrl.create({
header: 'Caution',

View File

@@ -0,0 +1,86 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="defaultBack"></ion-back-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content
[scrollEvents]="true"
(ionScroll)="handleScroll($event)"
(ionScrollEnd)="handleScrollEnd()"
class="ion-padding"
>
<ion-infinite-scroll
id="scroller"
*ngIf="!loading && needInfinite"
position="top"
threshold="0"
(ionInfinite)="doInfinite($event)"
>
<ion-infinite-scroll-content
loadingSpinner="lines"
></ion-infinite-scroll-content>
</ion-infinite-scroll>
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
<div id="container">
<div id="template"></div>
</div>
<ng-container *ngIf="!loading">
<div id="bottom-div"></div>
<div *ngIf="websocketFail" class="ion-text-center ion-padding">
<ion-text color="warning"> Websocket failed.... </ion-text>
</div>
<div
[ngStyle]="{
position: 'fixed',
bottom: '96px',
right: isOnBottom ? '-52px' : '30px',
'background-color': 'var(--ion-color-medium)',
'border-radius': '100%',
transition: 'right 0.25s ease-out'
}"
>
<ion-button
style="
width: 50px;
height: 50px;
--padding-start: 0px;
--padding-end: 0px;
--border-radius: 100%;
"
color="dark"
(click)="scrollToBottom(); autoScroll = true"
strong
>
<ion-icon name="chevron-down"></ion-icon>
</ion-button>
</div>
</ng-container>
</ion-content>
<ion-footer>
<ion-toolbar>
<div class="inline ion-padding-start">
<ion-checkbox [(ngModel)]="autoScroll" color="dark"></ion-checkbox>
<p class="ion-padding-start">Autoscroll</p>
</div>
<ion-button
*ngIf="!loading"
slot="end"
class="ion-padding-end"
fill="clear"
strong
(click)="download()"
>
Download
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -1,12 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { LogsPage } from './logs.page'
import { LogsComponent } from './logs.component'
import { FormsModule } from '@angular/forms'
import { TextSpinnerComponentModule } from '@start9labs/shared'
@NgModule({
declarations: [LogsPage],
imports: [CommonModule, IonicModule, TextSpinnerComponentModule],
exports: [LogsPage],
declarations: [LogsComponent],
imports: [CommonModule, IonicModule, TextSpinnerComponentModule, FormsModule],
exports: [LogsComponent],
})
export class LogsPageModule {}
export class LogsComponentModule {}

View File

@@ -0,0 +1,5 @@
#container {
padding-bottom: 16px;
font-family: monospace;
white-space: pre-line;
}

View File

@@ -0,0 +1,226 @@
import { DOCUMENT } from '@angular/common'
import { Component, Inject, Input, ViewChild } from '@angular/core'
import { IonContent, LoadingController } from '@ionic/angular'
import { map, takeUntil, timer } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import {
LogsRes,
ServerLogsReq,
DestroyService,
ErrorToastService,
toLocalIsoString,
Log,
DownloadHTMLService,
} from '@start9labs/shared'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
var Convert = require('ansi-to-html')
var convert = new Convert({
newline: true,
bg: 'transparent',
colors: {
4: 'Cyan',
},
escapeXML: true,
})
@Component({
selector: 'logs',
templateUrl: './logs.component.html',
styleUrls: ['./logs.component.scss'],
providers: [DestroyService, DownloadHTMLService],
})
export class LogsComponent {
@ViewChild(IonContent)
private content?: IonContent
@Input() followLogs!: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>
@Input() fetchLogs!: (params: ServerLogsReq) => Promise<LogsRes>
@Input() defaultBack!: string
@Input() title!: string
loading = true
needInfinite = true
startCursor?: string
isOnBottom = true
autoScroll = true
websocketFail = false
limit = 200
toProcess: Log[] = []
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly errToast: ErrorToastService,
private readonly destroy$: DestroyService,
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly downloadHtml: DownloadHTMLService,
) {}
async ngOnInit() {
try {
const { 'start-cursor': startCursor, guid } = await this.followLogs({
limit: 100,
})
this.startCursor = startCursor
const host = this.document.location.host
const protocol =
this.document.location.protocol === 'http:' ? 'ws' : 'wss'
const config: WebSocketSubjectConfig<Log> = {
url: `${protocol}://${host}/ws/rpc/${guid}`,
openObserver: {
next: () => {
console.log('**** LOGS WEBSOCKET OPEN ****')
this.websocketFail = false
this.processJob()
},
},
}
this.api
.openLogsWebsocket$(config)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: msg => {
this.toProcess.push(msg)
},
error: () => {
this.websocketFail = true
if (this.isOnBottom) this.scrollToBottom()
},
})
} catch (e: any) {
this.errToast.present(e)
}
}
async doInfinite(e: any): Promise<void> {
try {
const res = await this.fetchLogs({
cursor: this.startCursor,
before: true,
limit: this.limit,
})
this.processRes(res)
} catch (e: any) {
this.errToast.present(e)
} finally {
e.target.complete()
}
}
handleScroll(e: any) {
if (e.detail.deltaY < 0) this.autoScroll = false
}
handleScrollEnd() {
const bottomDiv = document.getElementById('bottom-div')
this.isOnBottom =
!!bottomDiv &&
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(250)
}
async download() {
const loader = await this.loadingCtrl.create({
message: 'Processing 10,000 logs...',
})
await loader.present()
try {
const { entries } = await this.fetchLogs({
before: true,
limit: 10000,
})
const styles = {
'background-color': '#222428',
color: '#e0e0e0',
'font-family': 'monospace',
}
const html = this.convertToAnsi(entries)
this.downloadHtml.download('logs.html', html, styles)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private processJob() {
timer(0, 500)
.pipe(
map((_, index) => index),
takeUntil(this.destroy$),
)
.subscribe(index => {
this.processRes({ entries: this.toProcess })
this.toProcess = []
if (index === 0) this.loading = false
})
}
private processRes(res: LogsRes) {
const { entries, 'start-cursor': startCursor } = res
if (!entries.length) return
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode()
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = this.convertToAnsi(entries)
// if respone contains startCursor, it means we are scrolling backwards
if (startCursor) {
this.startCursor = startCursor
const beforeContainerHeight = container?.scrollHeight || 0
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// scroll down
setTimeout(() => {
this.content?.scrollToPoint(
0,
afterContainerHeight - beforeContainerHeight,
)
}, 25)
if (entries.length < this.limit) {
this.needInfinite = false
}
} else {
container?.append(newLogs)
if (this.autoScroll) {
// scroll to bottom
setTimeout(() => {
this.scrollToBottom()
}, 25)
}
}
}
private convertToAnsi(entries: Log[]) {
return entries
.map(
entry =>
`<span style="color: #FFF; font-weight: bold;">${toLocalIsoString(
new Date(entry.timestamp),
)}</span>&nbsp;&nbsp;${convert.toHtml(entry.message)}`,
)
.join('<br />')
}
}

View File

@@ -1,61 +0,0 @@
<ion-content
[scrollEvents]="true"
(ionScroll)="scrollEvent()"
style="height: 100%"
class="ion-padding"
>
<ion-infinite-scroll
id="scroller"
*ngIf="!loading && needInfinite"
position="top"
threshold="0"
(ionInfinite)="doInfinite($event)"
>
<ion-infinite-scroll-content
loadingSpinner="lines"
></ion-infinite-scroll-content>
</ion-infinite-scroll>
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
<div id="container">
<div
id="template"
style="white-space: pre-line; font-family: monospace"
></div>
</div>
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center">
<ion-button *ngIf="!loadingNext" (click)="getNext()" strong color="dark">
Load More
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
<ion-spinner *ngIf="loadingNext" name="lines" color="warning"></ion-spinner>
</div>
<div
*ngIf="!loading"
[ngStyle]="{
'position': 'fixed',
'bottom': '36px',
'right': isOnBottom ? '-52px' : '36px',
'background-color': 'var(--ion-color-medium)',
'border-radius': '100%',
'transition': 'right 0.4s ease-out'
}"
>
<ion-button
style="
width: 50px;
height: 50px;
--padding-start: 0px;
--padding-end: 0px;
--border-radius: 100%;
"
color="dark"
(click)="scrollToBottom()"
strong
>
<ion-icon name="chevron-down"></ion-icon>
</ion-button>
</div>
</ion-content>

View File

@@ -1,3 +0,0 @@
#container {
padding-bottom: 16px;
}

View File

@@ -1,139 +0,0 @@
import { formatDate } from '@angular/common'
import { Component, Input, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
import { RR } from 'src/app/services/api/api.types'
var Convert = require('ansi-to-html')
var convert = new Convert({
bg: 'transparent',
colors: {
4: 'Cyan',
},
escapeXML: true,
})
@Component({
selector: 'logs',
templateUrl: './logs.page.html',
styleUrls: ['./logs.page.scss'],
})
export class LogsPage {
@ViewChild(IonContent)
private content?: IonContent
@Input()
fetchLogs!: (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => Promise<RR.LogsRes>
loading = true
loadingNext = false
needInfinite = true
startCursor?: string
endCursor?: string
limit = 400
isOnBottom = true
constructor(private readonly errToast: ErrorToastService) {}
async ngOnInit() {
await this.getPrior()
this.loading = false
}
async getNext() {
this.loadingNext = true
const logs = await this.fetch(false)
if (!logs?.length) return (this.loadingNext = false)
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode(true)
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML =
logs
.map(
l =>
`<b>${toLocalIsoString(new Date(l.timestamp))}</b> ${convert.toHtml(
l.message,
)}`,
)
.join('\n') + (logs.length ? '\n' : '')
container?.append(newLogs)
this.loadingNext = false
this.scrollEvent()
}
async doInfinite(e: any): Promise<void> {
await this.getPrior()
e.target.complete()
}
scrollEvent() {
const buttonDiv = document.getElementById('button-div')
this.isOnBottom =
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(500)
}
private async getPrior() {
// get logs
const logs = await this.fetch()
if (!logs?.length) return
const container = document.getElementById('container')
const beforeContainerHeight = container?.scrollHeight || 0
const newLogs = document.getElementById('template')?.cloneNode(true)
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML =
logs
.map(
l =>
`<b>${toLocalIsoString(new Date(l.timestamp))}</b> ${convert.toHtml(
l.message,
)}`,
)
.join('\n') + (logs.length ? '\n' : '')
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// scroll down
scrollBy(0, afterContainerHeight - beforeContainerHeight)
this.content?.scrollToPoint(0, afterContainerHeight - beforeContainerHeight)
if (logs.length < this.limit) {
this.needInfinite = false
}
}
private async fetch(isBefore: boolean = true) {
try {
const cursor = isBefore ? this.startCursor : this.endCursor
const logsRes = await this.fetchLogs({
cursor,
before_flag: !!cursor ? isBefore : undefined,
limit: this.limit,
})
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
this.startCursor = logsRes['start-cursor']
}
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
this.endCursor = logsRes['end-cursor']
}
return logsRes.entries
} catch (e: any) {
this.errToast.present(e)
}
}
}

View File

@@ -1,22 +1,18 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-title>Execution Complete</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Execution Complete</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>
<h2>{{ actionRes.message }}</h2>
</ion-label>
</ion-item>
<ion-content class="ion-padding">
<h2 class="ion-padding">{{ actionRes.message }}</h2>
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 64px 0">
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
<qr-code [value]="actionRes.value" size="240"></qr-code>
</div>

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { ActionResponse } from 'src/app/services/api/api.types'
import { copyToClipboard } from 'src/app/util/web.util'
import { copyToClipboard } from '@start9labs/shared'
@Component({
selector: 'action-success',

View File

@@ -25,7 +25,7 @@
</ion-item>
<ng-template #noError>
<ng-container *ngIf="hasConfig && !pkg.installed?.status?.configured">
<ng-container *ngIf="configForm && !pkg.installed?.status?.configured">
<ng-container *ngIf="!original; else hasOriginal">
<h2
*ngIf="!configForm.dirty"
@@ -81,7 +81,7 @@
</ion-item>
<!-- no config -->
<ion-item *ngIf="!hasConfig">
<ion-item *ngIf="!configForm">
<ion-label>
<p>
No config options for {{ pkg.manifest.title }} {{
@@ -91,7 +91,11 @@
</ion-item>
<!-- has config -->
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
<form
*ngIf="configForm && configSpec"
[formGroup]="configForm"
novalidate
>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
@@ -107,7 +111,7 @@
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!loading && !loadingError">
<ion-buttons *ngIf="hasConfig" slot="start" class="ion-padding-start">
<ion-buttons *ngIf="configForm" slot="start" class="ion-padding-start">
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
@@ -115,7 +119,7 @@
</ion-buttons>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
*ngIf="hasConfig"
*ngIf="configForm"
fill="solid"
color="primary"
[disabled]="saving"
@@ -126,7 +130,7 @@
Save
</ion-button>
<ion-button
*ngIf="!hasConfig"
*ngIf="!configForm"
fill="solid"
color="dark"
(click)="dismiss()"

View File

@@ -34,19 +34,18 @@ import { Breakages } from 'src/app/services/api/api.types'
export class AppConfigPage {
@Input() pkgId!: string
@Input()
dependentInfo?: DependentInfo
@Input() dependentInfo?: DependentInfo
pkg!: PackageDataEntry
loadingText!: string
configSpec!: ConfigSpec
configForm!: UntypedFormGroup
loadingText = ''
configSpec?: ConfigSpec
configForm?: UntypedFormGroup
original?: object // only if existing config
diff?: string[] // only if dependent info
loading = true
hasConfig = false
hasNewOptions = false
saving = false
loadingError: string | IonicSafeString = ''
@@ -64,9 +63,8 @@ export class AppConfigPage {
async ngOnInit() {
try {
this.pkg = await getPackage(this.patch, this.pkgId)
this.hasConfig = !!this.pkg.manifest.config
if (!this.hasConfig) return
if (!this.pkg.manifest.config) return
let newConfig: object | undefined
let patch: Operation[] | undefined
@@ -118,7 +116,7 @@ export class AppConfigPage {
}
async dismiss() {
if (this.configForm.dirty) {
if (this.configForm?.dirty) {
this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
@@ -126,9 +124,9 @@ export class AppConfigPage {
}
async tryConfigure() {
convertValuesRecursive(this.configSpec, this.configForm)
convertValuesRecursive(this.configSpec!, this.configForm!)
if (this.configForm.invalid) {
if (this.configForm!.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
@@ -153,7 +151,7 @@ export class AppConfigPage {
try {
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config: this.configForm.value,
config: this.configForm!.value,
})
if (isEmptyObject(breakages)) {
@@ -186,7 +184,7 @@ export class AppConfigPage {
try {
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config: this.configForm.value,
config: this.configForm!.value,
})
this.modalCtrl.dismiss()
} catch (e: any) {
@@ -304,11 +302,11 @@ export class AppConfigPage {
return isNaN(num) ? node : num
})
if (op.op !== 'remove') this.configForm.get(arrPath)?.markAsDirty()
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm.get(prevPath)?.markAsDirty()
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}

View File

@@ -1,14 +1,13 @@
import { Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController, ToastController } from '@ionic/angular'
import { getPkgId } from '@start9labs/shared'
import { getPkgId, copyToClipboard } from '@start9labs/shared'
import { getUiInterfaceKey } from 'src/app/services/config.service'
import {
InstalledPackageDataEntry,
InterfaceDef,
} from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { copyToClipboard } from 'src/app/util/web.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { getPackage } from '../../../util/get-package-data'

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppLogsPage } from './app-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [AppLogsPage],
})

View File

@@ -1,15 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Logs</ion-title>
<ion-button slot="end" fill="clear" size="small" (click)="copy()">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-toolbar>
</ion-header>
<div style="height: 100%">
<logs [fetchLogs]="fetchFetchLogs()"></logs>
</div>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
[defaultBack]="'/services/' + pkgId"
title="Service Logs"
class="ion-page"
></logs>

View File

@@ -1,9 +1,8 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { ToastController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { copyToClipboard, strip } from 'src/app/util/web.util'
import { RR } from 'src/app/services/api/api.types'
@Component({
selector: 'app-logs',
@@ -16,39 +15,23 @@ export class AppLogsPage {
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
return this.embassyApi.getPackageLogs({
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followPackageLogs({
id: this.pkgId,
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
...params,
})
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.cloneNode(true) as HTMLElement
const formatted = '```' + strip(logs.innerHTML) + '```'
const success = await copyToClipboard(formatted)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getPackageLogs({
id: this.pkgId,
...params,
})
}
}
}

View File

@@ -1,7 +1,6 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { copyToClipboard } from 'src/app/util/web.util'
import {
AlertController,
IonBackButtonDelegate,
@@ -13,7 +12,12 @@ import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { DestroyService, ErrorToastService, getPkgId } from '@start9labs/shared'
import {
DestroyService,
ErrorToastService,
getPkgId,
copyToClipboard,
} from '@start9labs/shared'
import { getValueByPointer } from 'fast-json-patch'
import { map, takeUntil } from 'rxjs/operators'

View File

@@ -9,31 +9,46 @@ const routes: Routes = [
},
{
path: 'list',
loadChildren: () => import('./app-list/app-list.module').then(m => m.AppListPageModule),
loadChildren: () =>
import('./app-list/app-list.module').then(m => m.AppListPageModule),
},
{
path: ':pkgId',
loadChildren: () => import('./app-show/app-show.module').then(m => m.AppShowPageModule),
loadChildren: () =>
import('./app-show/app-show.module').then(m => m.AppShowPageModule),
},
{
path: ':pkgId/actions',
loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule),
loadChildren: () =>
import('./app-actions/app-actions.module').then(
m => m.AppActionsPageModule,
),
},
{
path: ':pkgId/interfaces',
loadChildren: () => import('./app-interfaces/app-interfaces.module').then(m => m.AppInterfacesPageModule),
loadChildren: () =>
import('./app-interfaces/app-interfaces.module').then(
m => m.AppInterfacesPageModule,
),
},
{
path: ':pkgId/logs',
loadChildren: () => import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
loadChildren: () =>
import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: ':pkgId/metrics',
loadChildren: () => import('./app-metrics/app-metrics.module').then(m => m.AppMetricsPageModule),
loadChildren: () =>
import('./app-metrics/app-metrics.module').then(
m => m.AppMetricsPageModule,
),
},
{
path: ':pkgId/properties',
loadChildren: () => import('./app-properties/app-properties.module').then(m => m.AppPropertiesPageModule),
loadChildren: () =>
import('./app-properties/app-properties.module').then(
m => m.AppPropertiesPageModule,
),
},
]
@@ -41,4 +56,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppsRoutingModule { }
export class AppsRoutingModule {}

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { KernelLogsPage } from './kernel-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [KernelLogsPage],
})

View File

@@ -1,17 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Kernel Logs</ion-title>
<ion-buttons slot="end">
<ion-button (click)="copy()">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<div style="height: 100%">
<logs [fetchLogs]="fetchFetchLogs()"></logs>
</div>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
defaultBack="embassy"
title="Kernel Logs"
class="ion-page"
></logs>

View File

@@ -1,7 +1,6 @@
import { Component } from '@angular/core'
import { ToastController } from '@ionic/angular'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { copyToClipboard, strip } from 'src/app/util/web.util'
@Component({
selector: 'kernel-logs',
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
styleUrls: ['./kernel-logs.page.scss'],
})
export class KernelLogsPage {
constructor(
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
constructor(private readonly embassyApi: ApiService) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
return this.embassyApi.getKernelLogs({
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
})
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followKernelLogs(params)
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.cloneNode(true) as HTMLElement
const formatted = '```' + strip(logs.innerHTML) + '```'
const success = await copyToClipboard(formatted)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getKernelLogs(params)
}
}
}

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ServerLogsPage } from './server-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [ServerLogsPage],
})

View File

@@ -1,17 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>OS Logs</ion-title>
<ion-buttons slot="end">
<ion-button (click)="copy()">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<div style="height: 100%">
<logs [fetchLogs]="fetchFetchLogs()"></logs>
</div>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
defaultBack="embassy"
title="OS Logs"
class="ion-page"
></logs>

View File

@@ -1,7 +1,6 @@
import { Component } from '@angular/core'
import { ToastController } from '@ionic/angular'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { copyToClipboard, strip } from 'src/app/util/web.util'
@Component({
selector: 'server-logs',
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
styleUrls: ['./server-logs.page.scss'],
})
export class ServerLogsPage {
constructor(
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
constructor(private readonly embassyApi: ApiService) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
return this.embassyApi.getServerLogs({
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
})
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followServerLogs(params)
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.cloneNode(true) as HTMLElement
const formatted = '```' + strip(logs.innerHTML) + '```'
const success = await copyToClipboard(formatted)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getServerLogs(params)
}
}
}

View File

@@ -1,8 +1,8 @@
import { Component } from '@angular/core'
import { ToastController } from '@ionic/angular'
import { copyToClipboard } from 'src/app/util/web.util'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ConfigService } from 'src/app/services/config.service'
import { copyToClipboard } from '@start9labs/shared'
@Component({
selector: 'server-specs',

View File

@@ -7,16 +7,11 @@ import {
PackageState,
ServerStatusInfo,
} from 'src/app/services/patch-db/data-model'
import {
Log,
Metric,
RR,
NotificationLevel,
ServerNotifications,
} from './api.types'
import { Metric, RR, NotificationLevel, ServerNotifications } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON } from './api-icons'
import { MarketplacePkg } from '@start9labs/marketplace'
import { Log } from '@start9labs/shared'
export module Mock {
export const ServerUpdated: ServerStatusInfo = {
@@ -955,7 +950,7 @@ export module Mock {
{
timestamp: '2019-12-26T14:21:30.872Z',
message:
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.embassy/api/graphql \u001b[0;36;49m1.169406ms\u001b[0m unauthenticated<p>TEST PARAGRAPH</p>',
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.embassy/api/graphql \u001b[0;36;49m1.169406ms\u001b',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
@@ -1439,7 +1434,7 @@ export module Mock {
'bitcoin-node': {
name: 'Bitcoin Node Settings',
type: 'union',
description: 'The node settings',
description: 'Options<ul><li>Item 1</li><li>Item 2</li></ul>',
default: 'internal',
warning: 'Careful changing this',
tag: {

View File

@@ -7,6 +7,7 @@ import {
DependencyError,
Manifest,
} from 'src/app/services/patch-db/data-model'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export module RR {
// DB
@@ -28,13 +29,15 @@ export module RR {
// server
export type GetServerLogsReq = {
cursor?: string
before_flag?: boolean
limit?: number
}
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = LogsRes
export type FollowServerLogsReq = { limit?: number } // server.logs.follow & server.kernel-logs.follow
export type FollowServerLogsRes = {
'start-cursor': string
guid: string
}
export type GetServerMetricsReq = {} // server.metrics
export type GetServerMetricsRes = Metrics
@@ -160,20 +163,12 @@ export module RR {
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type LogsRes = {
entries: Log[]
'start-cursor'?: string
'end-cursor'?: string
}
export type GetPackageLogsReq = {
id: string
cursor?: string
before_flag?: boolean
limit?: number
} // package.logs
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
@@ -238,7 +233,7 @@ export module RR {
spec: ConfigSpec
}
export interface SideloadPackageReq {
export type SideloadPackageReq = {
manifest: Manifest
icon: string // base64
}
@@ -288,11 +283,6 @@ export interface TaggedDependencyError {
error: DependencyError
}
export interface Log {
timestamp: string
message: string
}
export interface ActionResponse {
message: string
value: string | null

View File

@@ -10,8 +10,9 @@ import {
} from 'patch-db-client'
import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { RequestError } from '../http.service'
import { Log, RequestError } from '@start9labs/shared'
import { map } from 'rxjs/operators'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
protected readonly sync$ = new Subject<Update<DataModel>>()
@@ -24,6 +25,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
.pipe(map(result => ({ result, jsonrpc: '2.0' })))
}
// websocket
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
// http
// for getting static files: ex icons, instructions, licenses
abstract getStatic(url: string): Promise<string>
@@ -62,6 +71,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes>
@@ -193,6 +210,10 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
abstract followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes>
protected abstract installPackageRaw(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
@@ -280,7 +301,7 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// }
return f(a)
.catch((e: RequestError) => {
.catch((e: UIRequestError) => {
if (e.revision) this.sync$.next(e.revision)
throw e
})
@@ -291,3 +312,5 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
}
}
}
type UIRequestError = RequestError & { revision: Revision }

View File

@@ -1,9 +1,11 @@
import { Injectable } from '@angular/core'
import { HttpService, Method } from '../http.service'
import { HttpService, Log, LogsRes, Method } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
@Injectable()
export class LiveApiService extends ApiService {
@@ -12,7 +14,11 @@ export class LiveApiService extends ApiService {
private readonly config: ConfigService,
) {
super()
; (window as any).rpcClient = this
; (window as any).rpcClient = this
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(config)
}
async getStatic(url: string): Promise<string> {
@@ -39,7 +45,7 @@ export class LiveApiService extends ApiService {
}
async getDump(): Promise<RR.GetDumpRes> {
return this.http.rpcRequest({ method: 'db.dump' })
return this.http.rpcRequest({ method: 'db.dump', params: {} })
}
async setDbValueRaw(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
@@ -78,6 +84,18 @@ export class LiveApiService extends ApiService {
return this.http.rpcRequest({ method: 'server.kernel-logs', params })
}
async followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.logs.follow', params })
}
async followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.kernel-logs.follow', params })
}
async getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> {
@@ -252,6 +270,12 @@ export class LiveApiService extends ApiService {
return this.http.rpcRequest({ method: 'package.logs', params })
}
async followPackageLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'package.logs.follow', params })
}
async getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { pauseFor, Log, LogsRes } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
import {
@@ -11,13 +11,14 @@ import {
PackageState,
ServerStatus,
} from 'src/app/services/patch-db/data-model'
import { CifsBackupTarget, Log, RR, WithRevision } from './api.types'
import { CifsBackupTarget, RR, WithRevision } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../../assets/markdown/md-sample.md'
import { BehaviorSubject } from 'rxjs'
import { BehaviorSubject, interval, map, Observable, tap } from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
const PROGRESS: InstallProgress = {
size: 120,
@@ -43,6 +44,16 @@ export class MockApiService extends ApiService {
super()
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(100).pipe(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
return Mock.ServerLogs[0]
}),
)
}
async getStatic(url: string): Promise<string> {
await pauseFor(2000)
return markdown
@@ -113,17 +124,8 @@ export class MockApiService extends ApiService {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
let entries: Log[]
if (Math.random() < 0.2) {
entries = Mock.ServerLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / Mock.ServerLogs.length)
: 10
entries = new Array(arrLength)
.fill(Mock.ServerLogs)
.reduce((acc, val) => acc.concat(val), [])
}
const entries = this.randomLogs(params.limit)
return {
entries,
'start-cursor': 'startCursor',
@@ -135,17 +137,8 @@ export class MockApiService extends ApiService {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
let entries: Log[]
if (Math.random() < 0.2) {
entries = Mock.ServerLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / Mock.ServerLogs.length)
: 10
entries = new Array(arrLength)
.fill(Mock.ServerLogs)
.reduce((acc, val) => acc.concat(val), [])
}
const entries = this.randomLogs(params.limit)
return {
entries,
'start-cursor': 'startCursor',
@@ -153,6 +146,35 @@ export class MockApiService extends ApiService {
}
}
async followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
await pauseFor(2000)
return {
'start-cursor': 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
}
}
async followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
await pauseFor(2000)
return {
'start-cursor': 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
}
}
randomLogs(limit = 1): Log[] {
const arrLength = Math.ceil(limit / Mock.ServerLogs.length)
const logs = new Array(arrLength)
.fill(Mock.ServerLogs)
.reduce((acc, val) => acc.concat(val), [])
return logs
}
async getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> {
@@ -485,6 +507,16 @@ export class MockApiService extends ApiService {
}
}
async followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes> {
await pauseFor(2000)
return {
'start-cursor': 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
}
}
async installPackageRaw(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {

View File

@@ -1,201 +0,0 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import {
Observable,
from,
interval,
race,
firstValueFrom,
lastValueFrom,
} from 'rxjs'
import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service'
import { Revision } from 'patch-db-client'
import { AuthService } from './auth.service'
import { HttpError, RpcError } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class HttpService {
fullUrl: string
constructor(
private readonly http: HttpClient,
private readonly config: ConfigService,
private readonly auth: AuthService,
) {
const port = window.location.port
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}`
}
// @ts-ignore TODO: fix typing
async rpcRequest<T>(rpcOpts: RPCOptions): Promise<T> {
const { url, version } = this.config.api
rpcOpts.params = rpcOpts.params || {}
const httpOpts: HttpOptions = {
method: Method.POST,
body: rpcOpts,
url: `/${url}/${version}`,
}
if (rpcOpts.timeout) httpOpts.timeout = rpcOpts.timeout
const res = await this.httpRequest<RPCResponse<T>>(httpOpts)
if (isRpcError(res)) {
// code 34 is authorization error ie. invalid session
if (res.error.code === 34) this.auth.setUnverified()
throw new RpcError(res.error)
}
return res.result
}
async httpRequest<T>(httpOpts: HttpOptions): Promise<T> {
if (httpOpts.withCredentials !== false) {
httpOpts.withCredentials = true
}
const urlIsRelative = httpOpts.url.startsWith('/')
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
const { params } = httpOpts
if (hasParams(params)) {
Object.keys(params).forEach(key => {
if (params[key] === undefined) {
delete params[key]
}
})
}
const options = {
responseType: httpOpts.responseType || 'json',
body: httpOpts.body,
observe: 'response',
withCredentials: httpOpts.withCredentials,
headers: httpOpts.headers,
params: httpOpts.params,
timeout: httpOpts.timeout,
} as any
let req: Observable<{ body: T }>
switch (httpOpts.method) {
case Method.GET:
req = this.http.get(url, options) as any
break
case Method.POST:
req = this.http.post(url, httpOpts.body, options) as any
break
case Method.PUT:
req = this.http.put(url, httpOpts.body, options) as any
break
case Method.PATCH:
req = this.http.patch(url, httpOpts.body, options) as any
break
case Method.DELETE:
req = this.http.delete(url, options) as any
break
}
return firstValueFrom(
httpOpts.timeout ? withTimeout(req, httpOpts.timeout) : req,
)
.then(res => res.body)
.catch(e => {
throw new HttpError(e)
})
}
}
function isRpcError<Error, Result>(
arg: { error: Error } | { result: Result },
): arg is { error: Error } {
return (arg as any).error !== undefined
}
export interface RequestError {
code: number
message: string
details: string
revision: Revision | null
}
export enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export interface RPCOptions {
method: string
params?: object
timeout?: number
}
interface RPCBase {
jsonrpc: '2.0'
id: string
}
export interface RPCRequest<T> extends RPCBase {
method: string
params?: T
}
export interface RPCSuccess<T> extends RPCBase {
result: T
}
export interface RPCError extends RPCBase {
error: {
code: number
message: string
data?:
| {
details: string
revision: Revision | null
debug: string | null
}
| string
}
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError
export interface HttpOptions {
method: Method
url: string
headers?:
| HttpHeaders
| {
[header: string]: string | string[]
}
params?:
| HttpParams
| {
[param: string]: string | string[]
}
responseType?: 'json' | 'text'
withCredentials?: boolean
body?: any
timeout?: number
}
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

@@ -1,23 +1,3 @@
export async function copyToClipboard(str: string): Promise<boolean> {
if (window.isSecureContext) {
return navigator.clipboard
.writeText(str)
.then(() => true)
.catch(() => false)
}
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
const copy = document.execCommand('copy')
document.body.removeChild(el)
return copy
}
export function strip(html: string) {
let doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent || ''