Merge pull request #59 from agnosticeng/feat/handle-errors
This commit is contained in:
104
src/lib/components/Console.svelte
Normal file
104
src/lib/components/Console.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" module>
|
||||
export interface Log {
|
||||
level: 'error';
|
||||
timestamp: Date;
|
||||
data: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Trash from '$lib/icons/Trash.svelte';
|
||||
|
||||
interface Props {
|
||||
logs: Log[];
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
let { logs, onClear }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#each logs as log}
|
||||
<div class="line">
|
||||
<span class="timestamp">{log.timestamp.toUTCString()}</span>
|
||||
<span class={log.level}>{log.data}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="footer">
|
||||
<button onclick={() => onClear?.()}><Trash size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
--footer-height: 22px;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 4px 7px;
|
||||
padding-bottom: var(--footer-height);
|
||||
position: relative;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
& > .line {
|
||||
display: block;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
|
||||
& .timestamp {
|
||||
display: block;
|
||||
|
||||
&::before {
|
||||
content: '-- ';
|
||||
}
|
||||
}
|
||||
|
||||
& .error {
|
||||
color: hsl(0deg 100% 90%);
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
& > .footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--footer-height);
|
||||
padding: 0 7px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
border-top: 1px solid hsl(0deg 0% 20%);
|
||||
|
||||
& > button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
|
||||
height: 100%;
|
||||
aspect-ratio: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:is(:hover):not(:disabled) {
|
||||
background-color: hsl(0deg 0% 10%);
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
background-color: hsl(0deg 0% 13%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,6 +36,6 @@ export const theme = syntaxHighlighting(
|
||||
{ tag: t.heading, fontWeight: 'bold', color: 'hsl(220deg 2% 90%)' },
|
||||
{ tag: [t.atom, t.bool], color: 'hsl(45, 7%, 75%)' },
|
||||
{ tag: [t.processingInstruction, t.string, t.inserted], color: 'hsl(41, 37%, 68%)' },
|
||||
{ tag: t.invalid, color: '#ff008c' }
|
||||
{ tag: t.invalid, color: 'hsl(327deg 100% 50%)' }
|
||||
])
|
||||
);
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import type { OLAPResponse } from '$lib/olap-engine';
|
||||
import { untrack } from 'svelte';
|
||||
import ChartContainer from './ChartContainer.svelte';
|
||||
import Console, { type Log } from './Console.svelte';
|
||||
|
||||
interface Props {
|
||||
response?: OLAPResponse;
|
||||
logs?: Log[];
|
||||
tab?: 'data' | 'chart' | 'logs';
|
||||
onClearLogs?: () => void;
|
||||
}
|
||||
|
||||
let { response }: Props = $props();
|
||||
let { response, logs = [], tab = $bindable('data'), onClearLogs }: Props = $props();
|
||||
|
||||
let tab = $state<'data' | 'chart'>('data');
|
||||
let yAxis = $state<string>('');
|
||||
let xAxis = $state<string>('');
|
||||
let chartType = $state('line');
|
||||
@@ -27,6 +30,7 @@
|
||||
<nav>
|
||||
<button aria-current={tab === 'data'} onclick={() => (tab = 'data')}>Data</button>
|
||||
<button aria-current={tab === 'chart'} onclick={() => (tab = 'chart')}>Chart</button>
|
||||
<button aria-current={tab === 'logs'} onclick={() => (tab = 'logs')}>Logs</button>
|
||||
</nav>
|
||||
<div>
|
||||
{#if response}
|
||||
@@ -36,6 +40,10 @@
|
||||
<ChartContainer {response} bind:xAxis bind:yAxis bind:type={chartType} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if tab === 'logs'}
|
||||
<Console {logs} onClear={onClearLogs} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
20
src/lib/icons/Trash.svelte
Normal file
20
src/lib/icons/Trash.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
|
||||
size?: string | number | null;
|
||||
}
|
||||
|
||||
let { size = 24, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" {...rest} data-name="trash">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
25
src/lib/olap-engine/Logger.ts
Normal file
25
src/lib/olap-engine/Logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
type Callback = (param: any) => void;
|
||||
|
||||
export interface ILogger {
|
||||
on(level: 'error', fn: Callback): () => void;
|
||||
log(level: 'error', param: any): void;
|
||||
}
|
||||
|
||||
export abstract class Logger implements ILogger {
|
||||
#listeners: { [key: string]: Set<Callback> } = {};
|
||||
|
||||
on(logEvent: 'error', fn: Callback) {
|
||||
this.#listeners[logEvent] ??= new Set<Callback>();
|
||||
|
||||
this.#listeners[logEvent].add(fn);
|
||||
return () => this.#listeners[logEvent].delete(fn);
|
||||
}
|
||||
|
||||
log(level: 'error', param: any) {
|
||||
if (!this.#listeners[level]?.size) return;
|
||||
|
||||
queueMicrotask(() => {
|
||||
for (const fn of this.#listeners[level]) fn(param);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { OLAPEngine, Table, OLAPResponse } from './index';
|
||||
import type { OLAPEngine, OLAPResponse, Table } from './index';
|
||||
|
||||
import { Logger } from './Logger';
|
||||
import CLICKHOUSE_GET_SCHEMA from './queries/clickhouse_get_schema.sql?raw';
|
||||
import CLICKHOUSE_INIT_DB from './queries/clickhouse_init_db.sql?raw';
|
||||
|
||||
export class CHDBEngine implements OLAPEngine {
|
||||
export class CHDBEngine extends Logger implements OLAPEngine {
|
||||
async init() {
|
||||
await this.exec(CLICKHOUSE_INIT_DB);
|
||||
}
|
||||
@@ -16,8 +17,9 @@ export class CHDBEngine implements OLAPEngine {
|
||||
|
||||
return JSON.parse(r) as OLAPResponse;
|
||||
} catch (e) {
|
||||
if (typeof e === 'string') e = new Error(e);
|
||||
console.error(e);
|
||||
return undefined;
|
||||
this.log('error', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { OLAPEngine, Table, OLAPResponse } from './index';
|
||||
import type { OLAPEngine, OLAPResponse, Table } from './index';
|
||||
import { Logger } from './Logger';
|
||||
|
||||
import CLICKHOUSE_GET_SCHEMA from './queries/clickhouse_get_schema.sql?raw';
|
||||
|
||||
export class RemoteEngine implements OLAPEngine {
|
||||
export class RemoteEngine extends Logger implements OLAPEngine {
|
||||
async init() {}
|
||||
|
||||
async exec(query: string) {
|
||||
try {
|
||||
const proxy =
|
||||
new URLSearchParams(window.location.search).get('proxy') ?? 'https://proxy.agx.app/query';
|
||||
const response = await fetch(`${proxy}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: query
|
||||
});
|
||||
const r = await response.text();
|
||||
if (!r) return;
|
||||
const response = await fetch(proxy, { method: 'POST', body: query });
|
||||
|
||||
return JSON.parse(r) as OLAPResponse;
|
||||
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||
|
||||
const r = await response.text();
|
||||
if (!r) throw new Error(`Empty Response`);
|
||||
|
||||
const data: RemoteEngineResponse = JSON.parse(r);
|
||||
|
||||
if ('exception' in data) throw new Error(data.exception);
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined;
|
||||
this.log('error', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +34,12 @@ export class RemoteEngine implements OLAPEngine {
|
||||
return response.data as Table[];
|
||||
}
|
||||
}
|
||||
|
||||
interface RemoteEngineException {
|
||||
meta: [];
|
||||
data: [];
|
||||
rows: 0;
|
||||
exception: string;
|
||||
}
|
||||
|
||||
type RemoteEngineResponse = OLAPResponse | RemoteEngineException;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { CHDBEngine } from './engine-chdb';
|
||||
import { RemoteEngine } from './engine-remote';
|
||||
import type { ILogger } from './Logger';
|
||||
|
||||
export type OLAPResponse = {
|
||||
meta: Array<ColumnDescriptor>;
|
||||
data: Array<{ [key: string]: any }>;
|
||||
rows: number;
|
||||
statistics: {
|
||||
bytes_read: number;
|
||||
elapsed: number;
|
||||
rows_read: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ColumnDescriptor {
|
||||
@@ -17,7 +24,7 @@ export interface Table {
|
||||
columns: ColumnDescriptor[];
|
||||
}
|
||||
|
||||
export interface OLAPEngine {
|
||||
export interface OLAPEngine extends ILogger {
|
||||
init(): Promise<void>;
|
||||
exec(query: string): Promise<OLAPResponse | undefined>;
|
||||
getSchema(): Promise<Table[]>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Log } from '$lib/components/Console.svelte';
|
||||
import { ContextMenuState } from '$lib/components/ContextMenu';
|
||||
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
|
||||
import Drawer from '$lib/components/Drawer.svelte';
|
||||
@@ -46,7 +47,10 @@
|
||||
|
||||
if (response && last?.content !== query) await addHistoryEntry(query);
|
||||
|
||||
if (response) bottomPanel.open = true;
|
||||
if (response) {
|
||||
bottomPanel.open = true;
|
||||
bottomPanelTab = 'data';
|
||||
}
|
||||
}
|
||||
|
||||
let tables = $state.raw<Table[]>([]);
|
||||
@@ -208,6 +212,16 @@
|
||||
|
||||
const bottomPanel = new PanelState('65%', false, '100%');
|
||||
const leftPanel = new PanelState('242px', true);
|
||||
|
||||
let bottomPanelTab = $state<'data' | 'chart' | 'logs'>('data');
|
||||
let errors = $state.raw<Log[]>([]);
|
||||
engine.on('error', (e) => {
|
||||
if (e instanceof Error) {
|
||||
errors = errors.concat({ level: 'error', timestamp: new Date(), data: e.message });
|
||||
bottomPanel.open = true;
|
||||
bottomPanelTab = 'logs';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} bind:innerWidth={screenWidth} />
|
||||
@@ -299,7 +313,12 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet b()}
|
||||
<Result {response} />
|
||||
<Result
|
||||
{response}
|
||||
logs={errors}
|
||||
bind:tab={bottomPanelTab}
|
||||
onClearLogs={() => (errors = [])}
|
||||
/>
|
||||
{/snippet}
|
||||
</SplitPane>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user