1
0
mirror of https://github.com/garraflavatra/rolens.git synced 2025-01-18 13:07:58 +00:00

Add ability to see host logs (#54)

Squashed commit of the following:

commit 93b2d67cef
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 20:07:33 2023 +0200

    Add filter functionality

commit 30b65a198f
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 19:27:20 2023 +0200

    Renamed `form-row` class to `formrow`

commit 21afb01ea1
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 19:26:04 2023 +0200

    Hide object types in object grid

commit 037d5432a4
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 19:21:54 2023 +0200

    Make auto reload interval input smaller

commit 49d5022027
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 15:08:00 2023 +0200

    Implement logs autoreload

commit 1f8984766b
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 15:04:00 2023 +0200

    Return on error

commit 9c85259964
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 15:03:37 2023 +0200

    Add log error handling

commit 7a98a63866
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 14:46:39 2023 +0200

    Update log tab icon

commit f30827ae2e
Author: Romein van Buren <romein@vburen.nl>
Date:   Sat Jul 1 14:41:59 2023 +0200

    Add host log panel
This commit is contained in:
Romein van Buren 2023-07-01 20:30:43 +02:00
parent 0b9f23365b
commit 24b0df95df
Signed by: romein
GPG Key ID: 0EFF8478ADDF6C49
20 changed files with 343 additions and 32 deletions

View File

@ -147,5 +147,7 @@
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01" /> <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01" />
{:else if name === 'loading'} {:else if name === 'loading'}
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /> <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
{:else if name === 'doc'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
{/if} {/if}
</svg> </svg>

View File

@ -5,7 +5,6 @@
<script> <script>
import { Beep } from '$wails/go/ui/UI'; import { Beep } from '$wails/go/ui/UI';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade, fly } from 'svelte/transition';
import Icon from './icon.svelte'; import Icon from './icon.svelte';
export let show = true; export let show = true;
@ -43,8 +42,8 @@
<svelte:window on:keydown={keydown} /> <svelte:window on:keydown={keydown} />
{#if show} {#if show}
<div class="modal outer" transition:fade on:pointerdown|self={Beep}> <div class="modal outer" on:pointerdown|self={Beep}>
<div class="inner" style:max-width={width || '80vw'} transition:fly={{ y: -100 }}> <div class="inner" style:max-width={width || '80vw'}>
{#if title} {#if title}
<header> <header>
<div class="title">{title}</div> <div class="title">{title}</div>

View File

@ -9,6 +9,7 @@
export let text = ''; export let text = '';
export let editor = undefined; export let editor = undefined;
export let readonly = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let editorParent; let editorParent;
@ -20,6 +21,7 @@
keymap.of([ indentWithTab, indentOnInput ]), keymap.of([ indentWithTab, indentOnInput ]),
javascript(), javascript(),
EditorState.tabSize.of(4), EditorState.tabSize.of(4),
EditorState.readOnly.of(readonly),
EditorView.updateListener.of(e => { EditorView.updateListener.of(e => {
if (!e.docChanged) { if (!e.docChanged) {
return; return;

View File

@ -11,15 +11,16 @@
export let errorTitle = ''; export let errorTitle = '';
export let errorDescription = ''; export let errorDescription = '';
export let busy = false; export let busy = false;
export let showTypes = true;
const columns = [
{ key: 'key', label: 'Key' },
{ key: 'value', label: 'Value' },
{ key: 'type', label: 'Type' },
];
let items = []; let items = [];
$: columns = [
{ key: 'key', label: 'Key' },
{ key: 'value', label: 'Value' },
showTypes ? { key: 'type', label: 'Type' } : undefined,
].filter(c => !!c);
$: if (data) { $: if (data) {
// items = dissectObject(data).map(item => ({ ...item, menu: getRootMenu(item.key, item) })); // items = dissectObject(data).map(item => ({ ...item, menu: getRootMenu(item.key, item) }));
items = []; items = [];

View File

@ -7,6 +7,7 @@
import ObjectEditor from './objecteditor.svelte'; import ObjectEditor from './objecteditor.svelte';
export let data; export let data;
export let readonly = false;
export let saveable = false; export let saveable = false;
export let successMessage = ''; export let successMessage = '';
@ -37,7 +38,7 @@
{#if data} {#if data}
<Modal bind:show={data} contentPadding={false}> <Modal bind:show={data} contentPadding={false}>
<div class="objectviewer"> <div class="objectviewer">
<ObjectEditor bind:text on:updated={() => successMessage = ''} /> <ObjectEditor bind:text on:updated={() => successMessage = ''} {readonly} />
</div> </div>
<svelte:fragment slot="footer"> <svelte:fragment slot="footer">

View File

@ -2,8 +2,10 @@ import { ObjectId } from 'bson';
import aggregationStages from './aggregation-stages.json'; import aggregationStages from './aggregation-stages.json';
import atomicUpdateOperators from './atomic-update-operators.json'; import atomicUpdateOperators from './atomic-update-operators.json';
import locales from './locales.json'; import locales from './locales.json';
import logComponents from './log-components.json';
import logLevels from './loglevels.json';
export { aggregationStages, atomicUpdateOperators, locales }; export { aggregationStages, atomicUpdateOperators, locales, logComponents, logLevels };
// Calculate the min and max values of (un)signed integers with n bits // Calculate the min and max values of (un)signed integers with n bits
export const intMin = bits => Math.pow(2, bits - 1) * -1; export const intMin = bits => Math.pow(2, bits - 1) * -1;

View File

@ -0,0 +1,36 @@
[
"ACCESS",
"COMMAND",
"CONTROL",
"ELECTION",
"FTDC",
"GEO",
"INDEX",
"INITSYNC",
"JOURNAL",
"NETWORK",
"QUERY",
"RECOVERY",
"REPL",
"REPL_HB",
"ROLLBACK",
"SHARDING",
"STORAGE",
"TXN",
"WRITE",
"WT",
"WTBACKUP",
"WTCHKPT",
"WTCMPCT",
"WTEVICT",
"WTHS",
"WTRECOV",
"WTRTS",
"WTSLVG",
"WTTIER",
"WTTS",
"WTTXN",
"WTVRFY",
"WTWRTLOG",
"-"
]

View File

@ -0,0 +1,7 @@
{
"F": "Fatal",
"E": "Error",
"W": "Warning",
"I": "Info",
"D": "Debug"
}

View File

@ -17,6 +17,7 @@ import {
DropDatabase, DropDatabase,
DropIndex, DropIndex,
GetIndexes, GetIndexes,
HostLogs,
Hosts, Hosts,
OpenCollection, OpenCollection,
OpenConnection, OpenConnection,
@ -263,17 +264,17 @@ async function refresh() {
}; };
} }
host.newDatabase = async function() {
const name = await dialogs.enterText('Create a database', 'Enter the database name. Note: databases in MongoDB do not exist until they have a collection and an item. Your new database will not persist on the server; fill it to have it created.', '');
if (name) {
host.databases[name] = { key: name, new: true };
await host.open();
}
};
await refresh(); await refresh();
}; };
host.newDatabase = async function() {
const name = await dialogs.enterText('Create a database', 'Enter the database name. Note: databases in MongoDB do not exist until they have a collection and an item. Your new database will not persist on the server; fill it to have it created.', '');
if (name) {
host.databases[name] = { key: name, new: true };
await host.open();
}
};
host.edit = async function() { host.edit = async function() {
const dialog = dialogs.new(HostDetailDialog, { hostKey }); const dialog = dialogs.new(HostDetailDialog, { hostKey });
return new Promise(resolve => { return new Promise(resolve => {
@ -283,6 +284,10 @@ async function refresh() {
}); });
}; };
host.getLogs = async function(filter = 'global') {
return await HostLogs(hostKey, filter);
};
host.remove = async function() { host.remove = async function() {
await RemoveHost(hostKey); await RemoveHost(hostKey);
await refresh(); await refresh();

View File

@ -164,7 +164,7 @@
<div class="find"> <div class="find">
<form on:submit|preventDefault={submitQuery}> <form on:submit|preventDefault={submitQuery}>
<div class="form-row one"> <div class="formrow one">
<label class="field"> <label class="field">
<span class="label">Query or id</span> <span class="label">Query or id</span>
<input type="text" <input type="text"
@ -192,7 +192,7 @@
</label> </label>
</div> </div>
<div class="form-row two"> <div class="formrow two">
<label class="field"> <label class="field">
<span class="label">Fields</span> <span class="label">Fields</span>
<input <input
@ -229,7 +229,7 @@
</label> </label>
</div> </div>
<div class="form-row actions"> <div class="formrow actions">
<button type="submit" class="button" title="Run query"> <button type="submit" class="button" title="Run query">
<Icon name="play" /> Run <Icon name="play" /> Run
</button> </button>
@ -342,18 +342,18 @@
grid-template: auto 1fr / 1fr; grid-template: auto 1fr / 1fr;
} }
.form-row { .formrow {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.form-row.one { .formrow.one {
grid-template: 1fr / 3fr 2fr; grid-template: 1fr / 3fr 2fr;
} }
.form-row.two { .formrow.two {
grid-template: 1fr / 5fr 1fr 1fr; grid-template: 1fr / 5fr 1fr 1fr;
} }
.form-row.actions { .formrow.actions {
margin-bottom: 0rem; margin-bottom: 0rem;
grid-template: 1fr / repeat(4, auto); grid-template: 1fr / repeat(4, auto);
justify-content: start; justify-content: start;

View File

@ -20,6 +20,7 @@
<div class="grid"> <div class="grid">
<ObjectGrid <ObjectGrid
data={collection.stats} data={collection.stats}
showTypes={false}
errorTitle={collection.statsError ? 'Error fetching collection stats' : ''} errorTitle={collection.statsError ? 'Error fetching collection stats' : ''}
errorDescription={collection.statsError} errorDescription={collection.statsError}
busy={!collection.stats && !collection.statsError && `Fetching stats for ${collection.key}`} busy={!collection.stats && !collection.statsError && `Fetching stats for ${collection.key}`}

View File

@ -20,6 +20,7 @@
<div class="grid"> <div class="grid">
<ObjectGrid <ObjectGrid
data={database.stats} data={database.stats}
showTypes={false}
errorTitle={database.statsError ? 'Error fetching database stats' : ''} errorTitle={database.statsError ? 'Error fetching database stats' : ''}
errorDescription={database.statsError} errorDescription={database.statsError}
busy={!database.stats && !database.statsError && `Fetching stats for ${database.key}`} busy={!database.stats && !database.statsError && `Fetching stats for ${database.key}`}

View File

@ -2,6 +2,8 @@
import BlankState from '$components/blankstate.svelte'; import BlankState from '$components/blankstate.svelte';
import TabBar from '$components/tabbar.svelte'; import TabBar from '$components/tabbar.svelte';
import { EventsOn } from '$wails/runtime/runtime'; import { EventsOn } from '$wails/runtime/runtime';
import Logs from './logs.svelte';
import Status from './status.svelte'; import Status from './status.svelte';
import SystemInfo from './systeminfo.svelte'; import SystemInfo from './systeminfo.svelte';
@ -23,14 +25,18 @@
<div class="view" class:empty={!host}> <div class="view" class:empty={!host}>
{#if host} {#if host}
{#key host} {#key host}
<TabBar tabs={[ <TabBar
{ key: 'status', icon: 'chart', title: 'Host status' }, tabs={[
{ key: 'systemInfo', icon: 'server', title: 'System info' }, { key: 'status', icon: 'chart', title: 'Host status' },
]} { key: 'logs', icon: 'doc', title: 'Logs' },
bind:selectedKey={tab} /> { key: 'systemInfo', icon: 'server', title: 'System info' },
]}
bind:selectedKey={tab}
/>
<div class="container"> <div class="container">
{#if tab === 'status'} <Status {host} /> {#if tab === 'status'} <Status {host} />
{:else if tab === 'logs'} <Logs {host} />
{:else if tab === 'systemInfo'} <SystemInfo {host} /> {:else if tab === 'systemInfo'} <SystemInfo {host} />
{/if} {/if}
</div> </div>

View File

@ -0,0 +1,194 @@
<script>
import Grid from '$components/grid.svelte';
import Icon from '$components/icon.svelte';
import ObjectViewer from '$components/objectviewer.svelte';
import input from '$lib/actions/input';
import { logComponents, logLevels } from '$lib/mongo';
import { BrowserOpenURL } from '$wails/runtime/runtime';
import { onDestroy } from 'svelte';
export let host;
const autoReloadIntervals = [ 1, 2, 5, 10, 30, 60 ];
let filter = 'global';
let severityFilter = '';
let componentFilter = '';
let logs;
let total = 0;
let error = '';
let copySucceeded = false;
let autoReloadInterval = 0;
let objectViewerData;
let interval;
$: (filter || severityFilter || componentFilter) && refresh();
$: busy = !logs && !error && 'Requesting logs…';
$: if (autoReloadInterval) {
if (interval) {
clearInterval(interval);
}
interval = setInterval(refresh, autoReloadInterval * 1000);
}
async function refresh() {
let _logs = [];
({ logs: _logs, total, error } = await host.getLogs(filter));
logs = [];
for (let index = 0; index < _logs.length; index++) {
const log = JSON.parse(_logs[index]);
const matchesLevel = severityFilter ? log.s?.startsWith(severityFilter) : true;
const matchesComponent = componentFilter ? (componentFilter === log.c?.toUpperCase()) : true;
if (matchesLevel && matchesComponent) {
log._index = index;
log.s = logLevels[log.s] || log.s;
logs = [ ...logs, log ];
}
}
}
function openFilterDocs() {
BrowserOpenURL('https://www.mongodb.com/docs/manual/reference/command/getLog/#command-fields');
}
function openLogDetail(event) {
objectViewerData = logs[event.detail.index];
}
async function copy() {
const json = JSON.stringify(host.status, undefined, '\t');
await navigator.clipboard.writeText(json);
copySucceeded = true;
setTimeout(() => copySucceeded = false, 1500);
}
onDestroy(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<div class="stats">
<div class="formrow">
<label class="field">
<span class="label">Auto reload (seconds)</span>
<input type="number" class="autoreloadinput" bind:value={autoReloadInterval} list="autoreloadintervals" use:input />
</label>
<label class="field">
<span class="label">Log type</span>
<select bind:value={filter}>
<option value="global">Global</option>
<option value="startupWarnings">Startup warnings</option>
</select>
<button class="button secondary" on:click={openFilterDocs} title="Documentation">
<Icon name="?" />
</button>
</label>
</div>
<div class="formrow">
<label class="field">
<span class="label">Severity</span>
<select bind:value={severityFilter}>
<option value="">All</option>
{#each Object.entries(logLevels) as [ value, name ]}
<option {value}>{value} ({name})</option>
{/each}
</select>
</label>
<label class="field">
<span class="label">Component</span>
<select bind:value={componentFilter}>
<option value="">All</option>
{#each logComponents as value}
<option {value}>{value}</option>
{/each}
</select>
</label>
</div>
<div class="grid">
<Grid
items={logs || []}
columns={[
{ title: 'Date', key: 't.$date' },
{ title: 'Severity', key: 's' },
{ title: 'ID', key: 'id' },
{ title: 'Component', key: 'c' },
{ title: 'Context', key: 'ctx' },
{ title: 'Message', key: 'msg' },
]}
key="_index"
showHeaders
errorTitle={error ? 'Error fetching server status' : ''}
errorDescription={error}
on:trigger={openLogDetail}
{busy}
/>
</div>
<div class="controls">
<button class="button" on:click={refresh}>
<Icon name="reload" spin={busy} /> Reload
</button>
<button class="button secondary" on:click={copy} disabled={!host.status}>
<Icon name={copySucceeded ? 'check' : 'clipboard'} />
Copy JSON
</button>
{#if total}
<div class="total">
Total: {total}
</div>
{/if}
</div>
</div>
{#if objectViewerData}
<ObjectViewer bind:data={objectViewerData} readonly />
{/if}
<datalist id="autoreloadintervals">
{#each autoReloadIntervals as value}
<option {value} />
{/each}
</datalist>
<style>
.stats {
display: grid;
gap: 0.5rem;
grid-template: auto auto 1fr auto / 1fr;
}
.formrow {
display: grid;
gap: 0.5rem;
grid-template: 1fr / 1fr 1fr;
}
.grid {
overflow: auto;
min-height: 0;
min-width: 0;
border: 1px solid #ccc;
}
.controls {
display: flex;
align-items: center;
gap: 0.2rem;
}
.total {
margin-left: auto;
}
.autoreloadinput {
width: 1.5rem;
}
</style>

View File

@ -20,6 +20,7 @@
<div class="grid"> <div class="grid">
<ObjectGrid <ObjectGrid
data={host.status || {}} data={host.status || {}}
showTypes={false}
errorTitle={host.statusError ? 'Error fetching server status' : ''} errorTitle={host.statusError ? 'Error fetching server status' : ''}
errorDescription={host.statusError} errorDescription={host.statusError}
busy={!host.status && !host.statusError && 'Fetching server status…'} busy={!host.status && !host.statusError && 'Fetching server status…'}

View File

@ -20,6 +20,7 @@
<div class="grid"> <div class="grid">
<ObjectGrid <ObjectGrid
data={host.systemInfo} data={host.systemInfo}
showTypes={false}
errorTitle={host.systemInfoError ? 'Error fetching system info' : ''} errorTitle={host.systemInfoError ? 'Error fetching system info' : ''}
errorDescription={host.systemInfoError} errorDescription={host.systemInfoError}
busy={!host.systemInfo && !host.systemInfoError && 'Fetching system info…'} busy={!host.systemInfo && !host.systemInfoError && 'Fetching system info…'}

View File

@ -142,7 +142,7 @@ select:disabled {
.field > textarea, .field > textarea,
.field > select { .field > select {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0 0.5rem;
border: 1px solid #ccc; border: 1px solid #ccc;
background-color: #fff; background-color: #fff;
appearance: none; appearance: none;

2
frontend/wailsjs/go/app/App.d.ts generated vendored
View File

@ -24,6 +24,8 @@ export function FindItems(arg1:string,arg2:string,arg3:string,arg4:string):Promi
export function GetIndexes(arg1:string,arg2:string,arg3:string):Promise<app.GetIndexesResult>; export function GetIndexes(arg1:string,arg2:string,arg3:string):Promise<app.GetIndexesResult>;
export function HostLogs(arg1:string,arg2:string):Promise<app.HostLogsResult>;
export function Hosts():Promise<map[string]app.Host>; export function Hosts():Promise<map[string]app.Host>;
export function InsertItems(arg1:string,arg2:string,arg3:string,arg4:string):Promise<any>; export function InsertItems(arg1:string,arg2:string,arg3:string,arg4:string):Promise<any>;

View File

@ -38,6 +38,10 @@ export function GetIndexes(arg1, arg2, arg3) {
return window['go']['app']['App']['GetIndexes'](arg1, arg2, arg3); return window['go']['app']['App']['GetIndexes'](arg1, arg2, arg3);
} }
export function HostLogs(arg1, arg2) {
return window['go']['app']['App']['HostLogs'](arg1, arg2);
}
export function Hosts() { export function Hosts() {
return window['go']['app']['App']['Hosts'](); return window['go']['app']['App']['Hosts']();
} }

46
internal/app/host_logs.go Normal file
View File

@ -0,0 +1,46 @@
package app
import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"go.mongodb.org/mongo-driver/bson"
)
type HostLogsResult struct {
Total int32 `json:"total"`
Logs []string `json:"logs"`
Error string `json:"error"`
}
func (a *App) HostLogs(hostKey, filter string) (result HostLogsResult) {
client, ctx, close, err := a.connectToHost(hostKey)
if err != nil {
result.Error = "Could not connect to host"
return
}
defer close()
var res bson.M
err = client.Database("admin").RunCommand(ctx, bson.M{"getLog": filter}).Decode(&res)
if err != nil {
runtime.LogWarningf(a.ctx, "Could not get %s logs for %s: %s", filter, hostKey, err.Error())
result.Error = err.Error()
return
}
if res["totalLinesWritten"] != nil {
result.Total = res["totalLinesWritten"].(int32)
} else {
result.Total = 0
}
result.Logs = make([]string, 0)
switch res["log"].(type) {
case bson.A:
for _, v := range res["log"].(bson.A) {
result.Logs = append(result.Logs, v.(string))
}
}
return
}