Add button bar to data view.

Add confirmation modal.
Better button styling
Save some of querySettings.
This commit is contained in:
Sage Vaillancourt 2022-08-22 19:33:35 -04:00
parent d7fe000d8b
commit bd0967df0e
7 changed files with 374 additions and 82 deletions

View File

@ -1,5 +1,5 @@
@import '@fontsource/fira-mono'; @import '@fontsource/fira-mono';
@import url('https://fonts.googleapis.com/css2?family=Fira+Sans&family=Montserrat:wght@500&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Fira+Sans&family=Montserrat:wght@500&family=Roboto&display=swap');
:root { :root {
font-family: 'Fira Sans', Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: 'Fira Sans', Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
@ -9,7 +9,8 @@
--primary-color: #e7f4ff; --primary-color: #e7f4ff;
--secondary-color: #d0dde9; --secondary-color: #d0dde9;
--tertiary-color: #edf0f8; --tertiary-color: #edf0f8;
--accent-color: #ff0df8; --accent-color: #de09fa;
/*--accent-color: #ff0df8;*/
--heading-color: rgba(0, 0, 0, 0.8); --heading-color: rgba(0, 0, 0, 0.8);
--text-color: #444444; --text-color: #444444;
--background-without-opacity: rgba(255, 255, 255, 0.7); --background-without-opacity: rgba(255, 255, 255, 0.7);
@ -18,7 +19,7 @@
} }
select, button { select, button {
font-family: 'Fira Sans', Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family: Roboto, Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 90%; font-size: 90%;
border-style: solid; border-style: solid;
border-radius: 0; border-radius: 0;
@ -28,7 +29,51 @@ select, button {
} }
button { button {
padding: 0.2em 0.8em; padding: 0.4em 1.0em;
border-radius: 5px;
border-style: solid;
background-color: white;
border-color: #999999;
width: auto;;
transition: 100ms linear;
font-weight: 600;
}
button:hover {
background-color: #eeeeee;
}
button.colored {
background-color: var(--accent-color);
font-weight: bold;
color: white;
border-style: solid;
border-width: 2px;
border-color: var(--accent-color);
}
button.colored:hover {
background-color: white;
font-weight: bold;
color: var(--accent-color);
border-color: var(--accent-color);
}
.danger {
background-color: #ee0000;
font-weight: bold;
color: white;
}
button.danger {
border-style: solid;
border-width: 2px;
border-color: #ee0000;
}
button.danger:hover {
background-color: black;
border-color: black;
} }
nav { nav {
@ -125,16 +170,17 @@ pre {
} }
.bracket { .bracket {
color: #d0dde9; color: #a0adb9;
} }
/*
input, input,
button { button {
font-size: inherit; font-size: inherit;
font-family: inherit; */
}
.data-view { .data-view {
font-family: monospace;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-style: solid; border-style: solid;
@ -143,7 +189,7 @@ button {
flex-grow: 2; flex-grow: 2;
padding: 1em; padding: 1em;
background-color: #e2eaff; background-color: #e2eaff;
height: 86vh; height: 84vh;
max-width: 65vw; max-width: 65vw;
min-width: 65vw; min-width: 65vw;
overflow-y: scroll; overflow-y: scroll;

82
src/lib/Modal.svelte Normal file
View File

@ -0,0 +1,82 @@
<script>
export let level = ''
export let title
export let onCancel = () => {}
export let onConfirm = () => {}
export let onClickOut = () => {}
</script>
<div class="modal" on:click|self={() => onClickOut && onClickOut()}>
<div class="modal-content" on:click={() => {}}>
{#if title}
<h2 class="modal-title">{title}</h2>
{/if}
<div class="modal-details">
<slot></slot>
</div>
<div class="modal-button-row">
<button on:click={onCancel}>Cancel</button>
<button class={level} on:click={onConfirm}>Confirm</button>
</div>
</div>
</div>
<style>
/* The Modal (background) */
.modal {
display: flex;
flex-direction: column;
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
left: 0;
top: 0;
width: 100vw; /* Full width */
height: 100vh; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}
/* Modal Content/Box */
.modal-content {
background-color: #fefefe;
margin: 15% auto; /* 15% from the top and centered */
padding: 2em;
border: 1px solid #888;
border-radius: 10px;
}
.modal-title {
font-size: 140%;
font-weight: bold;
margin-bottom: 1em;
margin-top: 0.5em;
}
.modal-details {
margin-bottom: 2em;
margin-top: 0.5em;
}
.modal-button-row {
margin-top: 1em;
display: flex;
flex-direction: row;
justify-content: space-between;
}
/* The Close Button */
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
</style>

View File

@ -14,10 +14,10 @@
<NavBar /> <NavBar />
<div class="corner"> <div class="corner">
<!--
{#if testMode} {#if testMode}
<div class="error-display">TEST MODE ENABLED</div> <div class="error-display">TEST MODE ENABLED</div>
{/if} {/if}
<!--
{#if $state.error} {#if $state.error}
<div class="error-display">{$state.error}</div> <div class="error-display">{$state.error}</div>
{:else} {:else}
@ -38,7 +38,6 @@
font-size: 18px; font-size: 18px;
color: black; color: black;
height: 3em; height: 3em;
padding: 1em;
} }
.corner a { .corner a {

View File

@ -1,7 +1,20 @@
// noinspection JSCheckFunctionSignatures // noinspection JSCheckFunctionSignatures
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { backendAddressAndPort, queryMode } from "./constants.js"; import { backendAddressAndPort, queryMode } from './constants.js'
const getRandomFromArray = array => array[Math.floor(Math.random() * array.length)]
let mockOffset = 1;
const addMetadata = mockItem => ({
value: mockItem,
magicByte: getRandomFromArray([0, 1, 2]),
attributes: 0,
timestamp: new Date().getTime(),
offset: mockOffset++,
key: null,
})
const mockItems = [ const mockItems = [
{ {
@ -19,41 +32,45 @@ const mockItems = [
"broughtToYouBy": "20th Century Fox", "broughtToYouBy": "20th Century Fox",
"title": "Star Wars 2: The Star Warsening, brought to you by the Big Stink Corporation, a division of PepsiCo and somehow also Disney." "title": "Star Wars 2: The Star Warsening, brought to you by the Big Stink Corporation, a division of PepsiCo and somehow also Disney."
}, },
].map(o => ({ value: o, timestamp: new Date().getTime() })) ].map(addMetadata)
export const testMode = true export const testMode = true
export const state = writable({ export const state = writable({
items: [], items: [],
itemCount: undefined, itemCount: undefined,
error: 'Connecting to WebSocket...', error: 'Connecting to WebSocket...',
}) })
const updateClearError = updater => state.update(s => { const updateClearError = updater => state.update(s => {
s.error = null s.error = null
return updater(s) return updater(s)
}) })
let itemLimit = Infinity
let ws; let ws;
let itemLimit = Infinity
let disconnected = false let disconnected = false
let filterFunc = (_message, _value) => true
const getRandomFromArray = array => array[Math.floor(Math.random() * array.length)] let runTestQuery = true
const testTimeout = 200 const testTimeout = 200
const testQuery = (mode, jsFilter, queryCode) => { const testQuery = (mode) => {
runTestQuery = true
try { try {
const f = new Function('message', 'value', queryCode)
if (mode === queryMode.REAL_TIME) { if (mode === queryMode.REAL_TIME) {
const addItem = () => { const addItem = () => {
const item = getRandomFromArray(mockItems) const item = getRandomFromArray(mockItems)
if (!jsFilter || f(item, item.value)) { if (filterFunc(item, item.value)) {
item.timestamp = new Date().getTime() item.timestamp = new Date().getTime()
state.update(s => ({ ...s, items: [item, ...s.items].slice(0, itemLimit), itemCount: 0 })) state.update(s => ({ ...s, items: [item, ...s.items].slice(0, itemLimit), itemCount: (s.itemCount || 0) + 1 }))
} }
if (runTestQuery) {
setTimeout(addItem, testTimeout) setTimeout(addItem, testTimeout)
} }
}
setTimeout(addItem, testTimeout) setTimeout(addItem, testTimeout)
} else { } else {
state.update(s => ({ ...s, items: mockItems.filter(item => !jsFilter || f(item, item.value)), itemCount: 0 })) state.update(s => ({ ...s, items: mockItems.filter(item => filterFunc(item, item.value)), itemCount: (s.itemCount || 0) + 1 }))
} }
} catch (e) { } catch (e) {
console.log('Caught an error:', e.toString()) console.log('Caught an error:', e.toString())
@ -61,30 +78,49 @@ const testQuery = (mode, jsFilter, queryCode) => {
} }
} }
// noinspection JSUnusedGlobalSymbols export const killQuery = async ({ }) => {
export const query = async ({ cluster, topic, mode, jsFilter, queryCode, maxItems, mutableObjects }) => { // TODO
updateClearError(s => ({ ...s, items: [], itemCount: 0 }))
if (testMode) { if (testMode) {
testQuery(mode, jsFilter, queryCode) runTestQuery = false
return return
} }
}
// noinspection JSUnusedGlobalSymbols
export const query = async ({ cluster, topic, mode, jsFilter, queryCode, maxItems, mutableObjects }) => {
if (jsFilter) {
filterFunc = new Function('message', 'value', queryCode)
} else {
filterFunc = () => true
}
itemLimit = maxItems
updateClearError(s => ({ ...s, items: [], itemCount: 0 }))
if (testMode) {
testQuery(mode)
return
}
if (disconnected) { if (disconnected) {
connect() connect()
disconnected = false disconnected = false
} }
updateClearError(s => ({ ...s, items: [], itemCount: 0 }))
itemLimit = maxItems
ws.send(JSON.stringify({ ws.send(JSON.stringify({
cluster, cluster,
mode, mode,
topic, topic,
maxItems, maxItems,
immutable: !mutableObjects, immutable: !mutableObjects,
searchCode: jsFilter && queryCode searchCode: false //jsFilter && queryCode
})) }))
} }
export const connect = () => { export const connect = () => {
if (testMode) {
updateClearError(s => ({ ...s, error: 'TEST MODE ENABLED' }))
return
}
try { try {
ws = new WebSocket(`ws://${backendAddressAndPort}`) ws = new WebSocket(`ws://${backendAddressAndPort}`)
} catch (e) { } catch (e) {
@ -117,10 +153,13 @@ export const connect = () => {
})) }))
break; break;
case 'message': case 'message':
if (filterFunc(data.message)) {
updateClearError(s => ({ updateClearError(s => ({
...s, ...s,
items: console.log('new item', data.message) || [data.message, ...s.items].slice(0, itemLimit) items: console.log('new item', data.message) || [data.message, ...s.items].slice(0, itemLimit),
itemCount: (s.itemCount || 0) + 1
})) }))
}
break; break;
} }
} catch (e) { } catch (e) {

View File

@ -1,9 +1,9 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { JsonView } from '@zerodevx/svelte-json-view' import { JsonView } from '@zerodevx/svelte-json-view'
import { state, connect, query } from '$lib/state'; import { state, connect, query, killQuery } from '$lib/state'
import { queryMode } from "../lib/constants.js"; import { queryMode } from "../lib/constants.js"
import { apiFetch } from "../utils.js"; import { apiFetch } from "../utils.js"
let expandAll = true let expandAll = true
let showMetadata = true let showMetadata = true
@ -31,12 +31,52 @@ return message.value.eventType === "Television";`
console.log('topics', topics) console.log('topics', topics)
querySettings.topic = topics[0] querySettings.topic = topics[0]
} }
const fontSizes = [30, 50, 67, 80, 90, 100, 110, 120, 133, 150]
let dataViewFontSize = 5
const setFontSize = size => {
dataViewFontSize = size
localStorage.dataViewFontSize = size
}
const fontUp = () => setFontSize(Math.min(dataViewFontSize + 1, fontSizes.length - 1))
const fontDown = () => setFontSize(Math.max(dataViewFontSize - 1, 0))
onMount(async () => { onMount(async () => {
connect(); connect()
clusters = Object.keys((await apiFetch('/clusters')) || {}) clusters = Object.keys((await apiFetch('/clusters')) || {})
querySettings.cluster = clusters[0] querySettings.cluster = clusters[0]
await updateTopics() await updateTopics()
if (localStorage) {
dataViewFontSize = localStorage.dataViewFontSize || dataViewFontSize
if (localStorage.querySettings) {
let storedSettings = {}
try {
storedSettings = JSON.parse(localStorage.querySettings)
} catch (e) {}
querySettings.maxItems = storedSettings?.maxItems || querySettings.maxItems
querySettings.jsFilter = storedSettings?.jsFilter || querySettings.jsFilter
querySettings.queryCode = storedSettings?.queryCode || querySettings.queryCode
querySettings.mode = storedSettings?.mode || querySettings.mode
if (clusters.includes(storedSettings?.cluster)) {
querySettings.cluster = storedSettings.cluster
}
if (topics.includes(storedSettings?.topic)) {
querySettings.topic = storedSettings.topic
}
}
}
}) })
let queryRunning = false
const startQuery = () => {
queryRunning = true
localStorage.setItem('querySettings', JSON.stringify(querySettings))
query(querySettings)
}
const stopQuery = () => {
queryRunning = false
killQuery(querySettings)
}
</script> </script>
<svelte:head> <svelte:head>
@ -110,22 +150,42 @@ return message.value.eventType === "Television";`
</div> </div>
<div class="query-button"> <div class="query-button">
<button on:click={() => query(querySettings)}>Start Query</button> {#if queryRunning}
<button class="danger" on:click={() => stopQuery()}>Stop Query</button>
{:else}
<button class="colored" on:click={() => startQuery()}>Start Query</button>
{/if}
</div> </div>
<br> <br>
<div class="live-settings"> <div class="live-settings">
<div>Expand All Objects <input type=checkbox bind:checked={expandAll}></div>
<div>Show Metadata <input type=checkbox bind:checked={showMetadata}></div>
<!--{#if $state.itemCount >= 0} <!--{#if $state.itemCount >= 0}
<h2>{$state.itemCount} Matches Found...</h2> <h2>{$state.itemCount} Matches Found...</h2>
{/if}--> {/if}-->
</div> </div>
</div> </div>
<div class="data-view-container">
<div class="data-view-bar">
<div>
<button class={expandAll ? 'selected' : ''} on:click={() => expandAll = !expandAll}>
Expand All Objects
</button>
<button class={showMetadata ? 'selected' : ''} on:click={() => showMetadata = !showMetadata}>
Show Metadata
</button>
<button on:click={fontUp}>+</button>
<button on:click={fontDown}>-</button>
</div>
{#if $state.itemCount >= 0}
<div class="data-view-results">
{$state.itemCount} Messages
</div>
{/if}
</div>
{#if $state.items?.length > 0} {#if $state.items?.length > 0}
<div class="data-view"> <div class="data-view" style={`font-size: ${fontSizes[dataViewFontSize]}%`}>
<JsonView json={console.log('state.items', $state.items) || showMetadata ? $state.items : $state.items.map(item => item.value)} <JsonView json={showMetadata ? $state.items : $state.items.map(item => item.value)}
depth={expandAll ? Infinity : 1}/> depth={expandAll ? Infinity : 1}/>
</div> </div>
{:else } {:else }
@ -133,9 +193,44 @@ return message.value.eventType === "Television";`
<h2>No query data</h2> <h2>No query data</h2>
</div> </div>
{/if} {/if}
</div>
</section> </section>
<style> <style>
.data-view {
border-top: none;
}
.data-view-bar {
background-color: #d7dbf3;
border-style: solid;
border-width: 1px;
border-color: #95c1d3;
border-bottom: none;
padding: 0;
margin: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.data-view-bar button {
border-style: none;
border-radius: 0;
margin-right: 1px;
}
button.selected {
background-color: #4732a5;
color: white;
}
button.selected:hover {
background-color: #53429d;
color: white;
}
.data-view-results {
color: #333;
font-size: 80%;
margin-right: 1em;
}
section { section {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -171,7 +266,7 @@ return message.value.eventType === "Television";`
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
padding: 1em; padding: 1em;
height: 86vh; height: 83vh;
} }
.query-input-header { .query-input-header {

View File

@ -1,31 +1,12 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { backendUrl, saslAuthMethods } from "../../lib/constants.js"; import { saslAuthMethods } from "../../lib/constants.js";
import { apiFetch } from "../../utils.js"; import { apiFetch, findLast, deepCopy, postJson, putJson } from "../../utils.js";
import Modal from "../../lib/Modal.svelte";
const jsonRequest = type => async (path, object) => fetch(backendUrl + path, { const addCluster = async cluster => fetchClusters(await postJson('/clusters', cluster))
method: type, const updateCluster = async cluster => fetchClusters(await putJson('/clusters', cluster))
body: JSON.stringify(object), const deleteCluster = async clusterName => fetchClusters(await postJson('/clusters/delete', { clusterName }))
headers: {
'Content-Type': 'application/json'
}
})
const findLast = array => {
if (!array || array.length === 0) {
return undefined
}
return array[array.length - 1]
}
const deepCopy = object => JSON.parse(JSON.stringify(object))
const post = jsonRequest('POST')
const put = jsonRequest('PUT')
const addCluster = async cluster => fetchClusters(await post('/clusters', cluster))
const updateCluster = async cluster => fetchClusters(await put('/clusters', cluster))
const deleteCluster = async clusterName => fetchClusters(await post('/clusters/delete', { clusterName }))
let clusters = [] let clusters = []
const fetchClusters = async (response) => { const fetchClusters = async (response) => {
@ -45,9 +26,7 @@
} }
onMount(async () => { onMount(async () => {
await fetchClusters() await fetchClusters()
console.log('onMount', clusters)
}) })
console.log('instant', clusters)
const startNewCluster = () => { const startNewCluster = () => {
config = emptyConfig() config = emptyConfig()
@ -66,6 +45,18 @@
} }
}) })
let config = emptyConfig() let config = emptyConfig()
let showConfirmModal = false
const closeModal = () => {showConfirmModal = false}
let modalConfirmAction = closeModal
let modalTitle = ''
const buildDeleteModal = clusterName => {
showConfirmModal = true
modalTitle = `Are you sure you want to delete ${clusterName}?`
modalConfirmAction = () => deleteCluster(clusterName)
closeModal()
}
</script> </script>
<svelte:head> <svelte:head>
@ -143,7 +134,18 @@
<h2 class="cluster-title">{cluster.clusterName}</h2> <h2 class="cluster-title">{cluster.clusterName}</h2>
<div class="cluster-buttons"> <div class="cluster-buttons">
<button on:click={() => { config = deepCopy(clusters[clusterName]); }}>View & Edit</button> <button on:click={() => { config = deepCopy(clusters[clusterName]); }}>View & Edit</button>
<button on:click={() => deleteCluster(clusterName)}>Delete</button> <button class="danger" on:click={() => buildDeleteModal(clusterName)}>Delete</button>
{#if showConfirmModal}
<Modal level='danger'
onClickOut={closeModal}
onCancel={closeModal}
onConfirm={modalConfirmAction}
title={modalTitle}>
This will remove it from this instance of Kafka Dance for all users.<br>
<br>
This action cannot be undone.
</Modal>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@ -205,7 +207,7 @@
.query-settings { .query-settings {
margin-right: 1rem; margin-right: 1rem;
padding: 1em; padding: 1em;
height: 86vh; height: 83vh;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,6 +1,34 @@
import { backendUrl } from "$lib/constants.js"; import { backendUrl } from "$lib/constants.js";
import { state, testMode } from "$lib/state.js"; import { state, testMode } from "$lib/state.js";
export const deepCopy = object => JSON.parse(JSON.stringify(object))
export const getRandomFromArray = array => array[Math.floor(Math.random() * array.length)]
export const findLast = array => {
if (!array || array.length === 0) {
return undefined
}
return array[array.length - 1]
}
const jsonRequest = type => async (path, object) => fetch(path, {
method: type,
body: JSON.stringify(object),
headers: {
'Content-Type': 'application/json'
}
})
/**
*
* @param path
* @param object
* @returns {Promise<*|undefined>}
*/
export const postJson = (path, object) => jsonRequest('POST')(path, object)
export const putJson = (path, object) => jsonRequest('PUT')(path, object)
const mockApi = { const mockApi = {
'/clusters': { '/clusters': {
'TestCluster': { 'TestCluster': {
@ -21,15 +49,16 @@ const mockApi = {
* Returns the JSON response from the given path, or undefined if an error occurred * Returns the JSON response from the given path, or undefined if an error occurred
* Sets `state.error` if something goes wrong. * Sets `state.error` if something goes wrong.
* @param path * @param path
* @param options Options object to pass to `fetch()`
* @returns {Promise<any|undefined>} * @returns {Promise<any|undefined>}
*/ */
export const apiFetch = async path => { export const apiFetch = async (path, options = undefined) => {
if (testMode) { if (testMode) {
return mockApi[path] return mockApi[path]
} }
let response let response
try { try {
response = await fetch(`${backendUrl}${path}`) response = await fetch(`${backendUrl}${path}`, options)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
state.update(s => ({ ...s, error: 'Could not connect to the backend' })) state.update(s => ({ ...s, error: 'Could not connect to the backend' }))