1
0
mirror of https://github.com/garraflavatra/rolens.git synced 2025-06-29 05:55:11 +00:00

14 Commits

Author SHA1 Message Date
0965344f35 Merge branch 'shell_queries' of https://github.com/garraflavatra/mongodup into shell_queries 2023-07-01 21:23:42 +02:00
62f4b88ea6 Added log view to changelog 2023-07-01 20:32:41 +02:00
04221bf63f Add ability to see host logs (#54)
This adds a 'Log' view to the host panel using the [`getLog`
command](https://www.mongodb.com/docs/manual/reference/command/getLog/).

Fixes #53.
2023-07-01 20:31:12 +02:00
24b0df95df 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
2023-07-01 20:30:43 +02:00
0b9f23365b Add app name and version to mongo connections 2023-07-01 10:35:41 +02:00
84f1b85356 Attempt to fix build.js 2023-06-30 20:09:36 +02:00
86639f766d Remove temporary script file when it has been used 2023-06-25 15:38:06 +02:00
d1ce48c773 Changed script file extension 2023-06-25 10:21:30 +02:00
b0d3e31378 Made shell available for hosts and databases (besides collections) 2023-06-25 08:49:57 +02:00
48b26c9df5 Parse connection URI correctly 2023-06-24 11:26:02 +02:00
958a0197a4 Connect to mongosh using the right credentials 2023-06-24 11:05:19 +02:00
3893c8dd06 CSS tweaks for shell 2023-06-24 11:05:02 +02:00
51897adf8d Show spinner 2023-06-24 10:28:49 +02:00
c284cb4cfc Started working on shell feature (WIP) 2023-06-24 10:11:32 +02:00
32 changed files with 771 additions and 112 deletions

View File

@ -73,7 +73,7 @@ jobs:
path: releases/*
- name: Test build script for users
run: ./build.js
run: node ./build.js
bundle:
name: Bundle artifacts

View File

@ -1,3 +1,7 @@
## [Unreleased]
* Added log view (#53, #54).
## [v0.2.2]
* Added Excel export format (#46).

View File

@ -36,7 +36,7 @@ You can obtain a pre-compiled Rolens binary for macOS or installer for Windows f
### Compiling from source
If you have Node.js installed, just download the source from GitHub, and run `./build.js`. The install script will check that dependencies are present and build Rolens for you.
If you have Node.js installed, just download the source from GitHub, and run `node ./build.js`. The install script will check that dependencies are present and build Rolens for you.
If you want to build it yourself, please refer to the [advanced build process documentation](https://garraflavatra.github.io/rolens/development/advanced-build/) for detailed compilation instructions.

View File

@ -52,32 +52,43 @@ function isNullish(val) {
// Check that Go ^1.18 is installed.
try {
const goMinorVersion = /go1\.([0-9][0-9])/.exec(
execSync('go version').toString()
)?.pop();
if (isNullish(goMinorVersion) || (parseInt(goMinorVersion) < 18)) {
throw new Error();
}
} catch {
missingDependencies.push({ name: 'Go ^1.18 ^16', url: 'https://go.dev/doc/install' });
}
// Check that Node.js ^16 is installed.
try {
const nodeMajorVersion = /v([0-9]{1,2})\.[0-9]{1,3}\.[0-9]{1,3}/.exec(
execSync('node --version').toString()
)?.pop();
if (isNullish(nodeMajorVersion) || (parseInt(nodeMajorVersion) < 16)) {
throw new Error();
}
} catch {
missingDependencies.push({ name: 'Node.js ^16', url: 'https://go.dev/doc/install' });
}
// Check that Wails is installed.
try {
const wailsMinorVersion = /v2\.([0-9])\.[0-9]/.exec(
execSync('wails version').toString()
)?.pop();
if (isNullish(wailsMinorVersion) || (parseInt(wailsMinorVersion) < 3)) {
throw new Error();
}
} catch {
missingDependencies.push({
name: 'Wails ^2.3',
command: 'go install github.com/wailsapp/wails/v2/cmd/wails@latest',
@ -88,9 +99,18 @@ if (isNullish(wailsMinorVersion) || (parseInt(wailsMinorVersion) < 3)) {
// Check that NSIS is installed on Windows.
if (isWindows) {
try {
const nsisInstalled = /v3\.([0-9][0-9])/.test(execSync('makensis.exe /VERSION').toString());
if (!nsisInstalled) {
missingDependencies.push({ name: 'Nullsoft Install System ^3', url: 'https://nsis.sourceforge.io/Download' });
throw new Error();
}
} catch {
missingDependencies.push({
name: 'Nullsoft Install System ^3',
command: 'choco install nsis',
url: 'https://nsis.sourceforge.io/Download',
comment: 'Note: you should add makensis.exe to your path:\n setx /M PATH "%PATH%;C:\\Program Files (x86)\\NSIS\\Bin"'
});
}
}
@ -112,6 +132,10 @@ if (missingDependencies.length > 0) {
console.log(' Visit the following page for more information:');
console.log(` ${dependency.url}`);
}
if (dependency.comment) {
console.log(` ${dependency.comment}`);
}
}
process.exit(1);

View File

@ -23,7 +23,7 @@ If you use a Linux-based OS, please continue reading.
Rolens is free and open-source software, which means that you can compile it from source on your own machine by cloning [the repository](https://github.com/garraflavatra/rolens).
If you have Node.js installed, just download the source from GitHub, and run `./build.js`. The install script will check that dependencies are present and build Rolens for you. If you want to build it yourself, please continue reading.
If you have Node.js installed, just download the source from GitHub, and run `node ./build.js`. The install script will check that dependencies are present and build Rolens for you. If you want to build it yourself, please continue reading.
## Advanced build

View File

@ -0,0 +1,57 @@
<script>
import { indentWithTab } from '@codemirror/commands';
import { indentOnInput } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { createEventDispatcher, onMount } from 'svelte';
export let text = '';
export let editor = undefined;
export let extensions = [];
const dispatch = createEventDispatcher();
let editorParent;
const editorState = EditorState.create({
doc: '',
extensions: [
basicSetup,
keymap.of([ indentWithTab, indentOnInput ]),
EditorState.tabSize.of(4),
EditorView.updateListener.of(e => {
if (!e.docChanged) {
return;
}
text = e.state.doc.toString();
dispatch('updated', { text });
}),
...extensions,
],
});
onMount(() => {
editor = new EditorView({
parent: editorParent,
state: editorState,
});
dispatch('inited', { editor });
});
</script>
<div bind:this={editorParent} class="editor"></div>
<style>
.editor {
width: 100%;
background-color: #fff;
border-radius: var(--radius);
overflow: hidden;
}
.editor :global(.cm-editor) {
overflow: auto;
height: 100%;
}
</style>

View File

@ -147,5 +147,9 @@
<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'}
<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 === 'shell'}
<path d="m4 17 6-6-6-6M12 19h8" />
{: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}
</svg>

View File

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

View File

@ -1,41 +1,17 @@
<script>
import { indentWithTab } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { indentOnInput } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import CodeEditor from './codeeditor.svelte';
export let text = '';
export let editor = undefined;
export let readonly = false;
const dispatch = createEventDispatcher();
let editorParent;
const editorState = EditorState.create({
doc: '',
extensions: [
basicSetup,
keymap.of([ indentWithTab, indentOnInput ]),
const extensions = [
javascript(),
EditorState.tabSize.of(4),
EditorView.updateListener.of(e => {
if (!e.docChanged) {
return;
}
text = e.state.doc.toString();
dispatch('updated', { text });
}),
],
});
];
onMount(() => {
editor = new EditorView({
parent: editorParent,
state: editorState,
});
editor.dispatch({
changes: {
from: 0,
@ -43,23 +19,13 @@
insert: text,
},
});
dispatch('inited', { editor });
});
</script>
<div bind:this={editorParent} class="editor"></div>
<style>
.editor {
width: 100%;
background-color: #fff;
border-radius: var(--radius);
overflow: hidden;
}
.editor :global(.cm-editor) {
overflow: auto;
height: 100%;
}
</style>
<CodeEditor bind:editor
bind:text
on:inited
on:updated
{extensions}
{readonly}
/>

View File

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

View File

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

View File

@ -2,8 +2,10 @@ import { ObjectId } from 'bson';
import aggregationStages from './aggregation-stages.json';
import atomicUpdateOperators from './atomic-update-operators.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
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

@ -16,7 +16,9 @@ import {
DropCollection,
DropDatabase,
DropIndex,
ExecuteShellScript,
GetIndexes,
HostLogs,
Hosts,
OpenCollection,
OpenConnection,
@ -223,6 +225,11 @@ async function refresh() {
});
});
};
collection.executeShellScript = async function(script) {
const result = await ExecuteShellScript(hostKey, dbKey, collKey, script);
return result;
};
}
await refresh();
@ -261,8 +268,21 @@ async function refresh() {
await database.open();
}
};
database.executeShellScript = async function(script) {
const result = await ExecuteShellScript(hostKey, dbKey, '', script);
return result;
};
}
await refresh();
};
host.executeShellScript = async function(script) {
const result = await ExecuteShellScript(hostKey, '', '', script);
return result;
};
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) {
@ -271,9 +291,6 @@ async function refresh() {
}
};
await refresh();
};
host.edit = async function() {
const dialog = dialogs.new(HostDetailDialog, { hostKey });
return new Promise(resolve => {
@ -283,6 +300,10 @@ async function refresh() {
});
};
host.getLogs = async function(filter = 'global') {
return await HostLogs(hostKey, filter);
};
host.remove = async function() {
await RemoveHost(hostKey);
await refresh();

View File

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

View File

@ -9,6 +9,7 @@
import Indexes from './indexes.svelte';
import Insert from './insert.svelte';
import Remove from './remove.svelte';
import Shell from '../shell.svelte';
import Stats from './stats.svelte';
import Update from './update.svelte';
@ -42,7 +43,8 @@
<div class="view" class:empty={!collection}>
{#if collection}
{#key collection}
<TabBar tabs={[
<TabBar
tabs={[
{ key: 'stats', icon: 'chart', title: 'Stats' },
{ key: 'find', icon: 'db', title: 'Find' },
{ key: 'insert', icon: '+', title: 'Insert' },
@ -50,8 +52,10 @@
{ key: 'remove', icon: 'trash', title: 'Remove' },
{ key: 'indexes', icon: 'list', title: 'Indexes' },
{ key: 'aggregate', icon: 're', title: 'Aggregate' },
{ key: 'shell', icon: 'shell', title: 'Shell' },
]}
bind:selectedKey={tab} />
bind:selectedKey={tab}
/>
<div class="container">
{#if tab === 'stats'} <Stats {collection} />
@ -61,6 +65,7 @@
{:else if tab === 'remove'} <Remove {collection} />
{:else if tab === 'indexes'} <Indexes {collection} />
{:else if tab === 'aggregate'} <Aggregate {collection} />
{:else if tab === 'shell'} <Shell {collection} />
{/if}
</div>
{/key}

View File

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

View File

@ -2,6 +2,8 @@
import BlankState from '$components/blankstate.svelte';
import TabBar from '$components/tabbar.svelte';
import { EventsOn } from '$wails/runtime/runtime';
import Shell from '../shell.svelte';
import Stats from './stats.svelte';
export let database;
@ -24,9 +26,15 @@
<div class="view" class:empty={!database}>
{#if database}
{#key database}
<TabBar tabs={[ { key: 'stats', icon: 'chart', title: 'Database stats' } ]} bind:selectedKey={tab} />
<TabBar
tabs={[
{ key: 'stats', icon: 'chart', title: 'Database stats' },
{ key: 'shell', icon: 'shell', title: 'Shell' },
]}
bind:selectedKey={tab} />
<div class="container">
{#if tab === 'stats'} <Stats {database} />
{:else if tab === 'shell'} <Shell {database} />
{/if}
</div>
{/key}

View File

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

View File

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

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">
<ObjectGrid
data={host.status || {}}
showTypes={false}
errorTitle={host.statusError ? 'Error fetching server status' : ''}
errorDescription={host.statusError}
busy={!host.status && !host.statusError && 'Fetching server status…'}

View File

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

View File

@ -0,0 +1,146 @@
<script>
import BlankState from '$components/blankstate.svelte';
import CodeEditor from '$components/codeeditor.svelte';
import Icon from '$components/icon.svelte';
import { javascript } from '@codemirror/lang-javascript';
import { onDestroy, onMount } from 'svelte';
export let host = undefined;
export let database = undefined;
export let collection = undefined;
const placeholder = '// Write your script here...';
const extensions = [ javascript() ];
let script = '';
let result = {};
let copySucceeded = false;
let timeout;
let busy = false;
let editor;
async function run() {
busy = true;
if (collection) {
result = await collection.executeShellScript(script);
}
else if (database) {
result = await database.executeShellScript(script);
}
else if (host) {
result = await host.executeShellScript(script);
}
busy = false;
}
async function copyErrorDescription() {
await navigator.clipboard.writeText(result.errorDescription);
copySucceeded = true;
timeout = setTimeout(() => copySucceeded = false, 1500);
}
onMount(() => {
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: placeholder,
},
selection: {
from: 0,
anchor: 0,
to: placeholder.length,
head: placeholder.length,
},
});
editor.focus();
});
onDestroy(() => clearTimeout(timeout));
</script>
<div class="shell">
<div class="overflow">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="field">
<CodeEditor bind:editor bind:text={script} {extensions} />
</label>
</div>
<div class="output">
{#if busy}
<BlankState icon="loading" label="Executing…" />
{:else if result.errorTitle || result.errorDescription}
<BlankState title={result.errorTitle} label={result.errorDescription} icon="!">
<button class="button-small" on:click={copyErrorDescription}>
<Icon name={copySucceeded ? 'check' : 'clipboard'} /> Copy error message
</button>
</BlankState>
{:else}
<pre>{result.output || ''}</pre>
{/if}
</div>
<div class="controls">
{#key result}
<div class="status flash-green">
{#if result?.status}
Exit code: {result.status}
{/if}
</div>
{/key}
<button class="btn" on:click={run}>
<Icon name="play" /> Run
</button>
</div>
</div>
<style>
.shell {
display: grid;
grid-template: 1fr auto / 1fr 1fr;
}
.overflow {
overflow: auto;
}
.field {
height: 100%;
}
.field :global(.editor) {
border-radius: 0;
}
.output {
background-color: #111;
color: #fff;
overflow: auto;
display: flex;
}
.output :global(*) {
color: #fff;
}
.output pre {
font-family: monospace;
padding: 0.5rem;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.output :global(.blankstate) {
margin: auto;
padding: 0.5rem;
}
.controls {
margin-top: 0.5rem;
display: flex;
align-items: center;
grid-column: 1 / 3;
}
.controls .status {
margin-right: auto;
}
</style>

View File

@ -50,8 +50,8 @@ a {
hr {
border: none;
height: 1px;
background-color: #ccc;
border-top: 1px solid #ccc;
width: 100%;
}
.loading {
@ -142,7 +142,7 @@ select:disabled {
.field > textarea,
.field > select {
flex: 1;
padding: 0.5rem;
padding: 0 0.5rem;
border: 1px solid #ccc;
background-color: #fff;
appearance: none;

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

@ -20,10 +20,14 @@ export function DropIndex(arg1:string,arg2:string,arg3:string,arg4:string):Promi
export function Environment():Promise<app.EnvironmentInfo>;
export function ExecuteShellScript(arg1:string,arg2:string,arg3:string,arg4:string):Promise<app.ExecuteShellScriptResult>;
export function FindItems(arg1:string,arg2:string,arg3:string,arg4:string):Promise<app.FindItemsResult>;
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 InsertItems(arg1:string,arg2:string,arg3:string,arg4:string):Promise<any>;

View File

@ -30,6 +30,10 @@ export function Environment() {
return window['go']['app']['App']['Environment']();
}
export function ExecuteShellScript(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ExecuteShellScript'](arg1, arg2, arg3, arg4);
}
export function FindItems(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['FindItems'](arg1, arg2, arg3, arg4);
}
@ -38,6 +42,10 @@ export function 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() {
return window['go']['app']['App']['Hosts']();
}

View File

@ -25,6 +25,7 @@ type EnvironmentInfo struct {
HasMongoExport bool `json:"hasMongoExport"`
HasMongoDump bool `json:"hasMongoDump"`
HasMongoShell bool `json:"hasMongoShell"`
HomeDirectory string `json:"homeDirectory"`
DataDirectory string `json:"dataDirectory"`
@ -49,6 +50,9 @@ func NewApp(version string) *App {
_, err = exec.LookPath("mongoexport")
a.Env.HasMongoExport = err == nil
_, err = exec.LookPath("mongosh")
a.Env.HasMongoShell = err == nil
a.Env.HomeDirectory, err = os.UserHomeDir()
if err != nil {
panic(errors.New("encountered an error while getting home directory"))

View File

@ -42,7 +42,11 @@ func (a *App) connectToHost(hostKey string) (*mongo.Client, context.Context, fun
return nil, nil, nil, errors.New("invalid uri")
}
client, err := mongo.NewClient(mongoOptions.Client().ApplyURI(h.URI))
appName := "Rolens v" + a.Env.Version
opts := mongoOptions.Client()
opts.AppName = &appName
opts.ApplyURI(h.URI)
client, err := mongo.NewClient(opts)
if err != nil {
runtime.LogWarningf(a.ctx, "Could not connect to host %s: %s", hostKey, err.Error())

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
}

105
internal/app/host_shell.go Normal file
View File

@ -0,0 +1,105 @@
package app
import (
"fmt"
"net/url"
"os"
"os/exec"
"path"
"github.com/google/uuid"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type ExecuteShellScriptResult struct {
Output string `json:"output"`
Status int `json:"status"`
ErrorTitle string `json:"errorTitle"`
ErrorDescription string `json:"errorDescription"`
}
func (a *App) ExecuteShellScript(hostKey, dbKey, collKey, script string) (result ExecuteShellScriptResult) {
if !a.Env.HasMongoShell {
result.ErrorTitle = "mongosh not found"
result.ErrorDescription = "The mongosh executable is required to run a shell script. Please see https://www.mongodb.com/docs/mongodb-shell/install/"
return
}
hosts, err := a.Hosts()
if err != nil {
result.ErrorTitle = "Could not get hosts"
result.ErrorDescription = err.Error()
return
}
host, hostFound := hosts[hostKey]
if !hostFound {
result.ErrorTitle = "The specified host does not seem to exist"
return
}
id, err := uuid.NewRandom()
if err != nil {
runtime.LogErrorf(a.ctx, "Shell: failed to generate a UUID: %s", err.Error())
result.ErrorTitle = "Could not generate UUID"
result.ErrorDescription = err.Error()
return
}
dirname := path.Join(a.Env.DataDirectory, "Shell Scripts")
fname := path.Join(dirname, fmt.Sprintf("%s.mongosh.js", id.String()))
if err := os.MkdirAll(dirname, os.ModePerm); err != nil {
runtime.LogWarningf(a.ctx, "Shell: failed to mkdir %s", err.Error())
result.ErrorTitle = "Could not create temporary directory"
result.ErrorDescription = err.Error()
return
}
scriptHeader := fmt.Sprintf("// Namespace: %s.%s\n", dbKey, collKey)
if dbKey != "" {
url, err := url.Parse(host.URI)
if err != nil {
runtime.LogWarningf(a.ctx, "Shell: failed to parse host URI %s: %s", host.URI, err.Error())
result.ErrorTitle = "Could parse host URI"
result.ErrorDescription = err.Error()
return
}
url.Path = "/" + dbKey
scriptHeader = scriptHeader + fmt.Sprintf("db = connect('%s');\n", url.String())
}
if collKey != "" {
scriptHeader = scriptHeader + fmt.Sprintf("coll = db.getCollection('%s');\n", collKey)
}
scriptHeader = scriptHeader + "\n// Start of user script\n"
script = scriptHeader + script
if err := os.WriteFile(fname, []byte(script), os.ModePerm); err != nil {
runtime.LogWarningf(a.ctx, "Shell: failed to write script to %s", err.Error())
result.ErrorTitle = "Could not create temporary script file"
result.ErrorDescription = err.Error()
return
}
cmd := exec.Command("mongosh", "--file", fname, host.URI)
stdout, err := cmd.Output()
if exiterr, ok := err.(*exec.ExitError); ok {
result.Status = exiterr.ExitCode()
} else if err != nil {
runtime.LogWarningf(a.ctx, "Shell: failed to execute: mongosh --file %s: %s", fname, err.Error())
result.ErrorTitle = "Could not execute script"
result.ErrorDescription = err.Error()
return
} else {
result.Status = 0
}
os.Remove(fname)
result.Output = string(stdout)
return
}