[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

@@ -7,41 +7,51 @@
</ion-toolbar>
</ion-header>
<ion-content
<ion-content
[scrollEvents]="true"
(ionScroll)="scrollEvent()"
style="height: 100%;"
id="ion-content"
(ionScrollEnd)="scrollEnd()"
class="ion-padding"
>
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
<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>
<div id="container">
<div id="template" style="white-space: pre-line;"></div>
</div>
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center;">
<ion-button *ngIf="!loadingMore" (click)="loadMore()" strong color="dark">
Load More
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
<ion-spinner *ngIf="loadingMore" name="lines" color="warning"></ion-spinner>
<div id="template" style="white-space: pre-line"></div>
</div>
<div id="bottom-div"></div>
<div
*ngIf="!loading"
[ngStyle]="{
'position': 'fixed',
'bottom': '50px',
'right': isOnBottom ? '-52px' : '30px',
'border-radius': '100%',
'transition': 'right 0.4s ease-out'
'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()" strong>
<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,7 +1,8 @@
import { Component, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { toLocalIsoString } from '@start9labs/shared'
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
var Convert = require('ansi-to-html')
var convert = new Convert({
bg: 'transparent',
@@ -15,122 +16,80 @@ var convert = new Convert({
export class LogsPage {
@ViewChild(IonContent) private content?: IonContent
loading = true
loadingMore = false
needInfinite = true
startCursor?: string
endCursor?: string
limit = 200
isOnBottom = true
constructor(private readonly api: ApiService) {}
constructor(
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
ngOnInit() {
this.getLogs()
async ngOnInit() {
await this.getLogs()
this.loading = false
}
async getLogs() {
try {
// 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
}
} catch (e) {}
}
async fetch(isBefore: boolean = true) {
try {
const cursor = isBefore ? this.startCursor : this.endCursor
const logsRes = await this.api.getLogs({
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']
}
this.loading = false
return logsRes.entries
} catch (e) {
console.error(e)
}
}
async loadMore() {
try {
this.loadingMore = true
const logs = await this.fetch(false)
if (!logs?.length) return (this.loadingMore = 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.loadingMore = false
this.scrollEvent()
} catch (e) {}
}
scrollEvent() {
const buttonDiv = document.getElementById('button-div')
scrollEnd() {
const bottomDiv = document.getElementById('bottom-div')
this.isOnBottom =
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
!!bottomDiv &&
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(500)
}
async loadData(e: any): Promise<void> {
async doInfinite(e: any): Promise<void> {
await this.getLogs()
e.target.complete()
}
private async getLogs() {
try {
const { 'start-cursor': startCursor, entries } = await this.api.getLogs({
cursor: this.startCursor,
before: !!this.startCursor,
limit: this.limit,
})
if (!entries.length) return
this.startCursor = startCursor
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode(true)
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = entries
.map(
entry =>
`<b>${toLocalIsoString(
new Date(entry.timestamp),
)}</b> ${convert.toHtml(entry.message)}`,
)
.join('\n')
const beforeContainerHeight = container?.scrollHeight || 0
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// scroll down
setTimeout(() => {
this.content?.scrollToPoint(
0,
afterContainerHeight - beforeContainerHeight,
)
}, 50)
if (entries.length < this.limit) {
this.needInfinite = false
}
} catch (e: any) {
this.errToast.present(e)
}
}
}

View File

@@ -1,9 +1,11 @@
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export abstract class ApiService {
abstract getError(): Promise<GetErrorRes>
abstract restart(): Promise<void>
abstract forgetDrive(): Promise<void>
abstract repairDisk(): Promise<void>
abstract getLogs(params: GetLogsReq): Promise<GetLogsRes>
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
}
export interface GetErrorRes {
@@ -11,21 +13,3 @@ export interface GetErrorRes {
message: string
data: { details: string }
}
export type GetLogsReq = {
cursor?: string
before_flag?: boolean
limit?: number
}
export type GetLogsRes = LogsRes
export type LogsRes = {
entries: Log[]
'start-cursor'?: string
'end-cursor'?: string
}
export interface Log {
timestamp: string
message: string
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'
import { HttpService } from '../http.service'
import { ApiService, GetErrorRes, GetLogsReq, GetLogsRes } from './api.service'
import { HttpService } from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
@Injectable()
export class LiveApiService extends ApiService {
@@ -36,8 +37,8 @@ export class LiveApiService extends ApiService {
})
}
getLogs(params: GetLogsReq): Promise<GetLogsRes> {
return this.http.rpcRequest<GetLogsRes>({
getLogs(params: ServerLogsReq): Promise<LogsRes> {
return this.http.rpcRequest<LogsRes>({
method: 'diagnostic.logs',
params,
})

View File

@@ -1,12 +1,7 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import {
ApiService,
GetErrorRes,
GetLogsReq,
GetLogsRes,
Log,
} from './api.service'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
@Injectable()
export class MockApiService extends ApiService {
@@ -35,7 +30,7 @@ export class MockApiService extends ApiService {
await pauseFor(1000)
}
async getLogs(params: GetLogsReq): Promise<GetLogsRes> {
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
await pauseFor(1000)
let entries: Log[]
if (Math.random() < 0.2) {

View File

@@ -1,71 +0,0 @@
import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { HttpError, RpcError } from '@start9labs/shared'
import { firstValueFrom } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class HttpService {
constructor(private readonly http: HttpClient) {}
async rpcRequest<T>(options: RPCOptions): Promise<T> {
const res = await this.httpRequest<RPCResponse<T>>(options)
if (isRpcError(res)) throw new RpcError(res.error)
return res.result
}
async httpRequest<T>(body: RPCOptions): Promise<T> {
const url = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/rpc/v1`
return firstValueFrom(this.http.post<T>(url, 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 RPCOptions {
method: string
params: { [param: string]: Params }
}
export interface RequestError {
code: number
message: string
details: string
}
export type Params = string | number | boolean | object | string[] | 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
}
| string
}
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError