Merge pull request #59 from agnosticeng/feat/handle-errors

This commit is contained in:
Yann Amsellem
2025-01-17 16:53:28 +01:00
committed by GitHub
9 changed files with 218 additions and 22 deletions

View 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>

View File

@@ -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%)' }
])
);

View File

@@ -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>

View 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>

View 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);
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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[]>;

View File

@@ -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}