mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
[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:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,5 @@
|
||||
#container {
|
||||
padding-bottom: 16px;
|
||||
font-family: monospace;
|
||||
white-space: pre-line;
|
||||
}
|
||||
226
frontend/projects/ui/src/app/components/logs/logs.component.ts
Normal file
226
frontend/projects/ui/src/app/components/logs/logs.component.ts
Normal 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> ${convert.toHtml(entry.message)}`,
|
||||
)
|
||||
.join('<br />')
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
#container {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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 || ''
|
||||
|
||||
Reference in New Issue
Block a user