Building a Native Elasticsearch Client in React
- Related: Building a Native IP Camera Client in React
- Github Repository: React-Query Elasticsearch Client
Create Tauri app using the frontend build tool Vite.
Scaffolding
Create the Vite Frontend
npm create vite@latest rusty-react-query
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
cd rusty-react-query
npm install
npm install @tanstack/react-query uuid vite-tsconfig-paths
npm install --save-dev @types/uuid @types/node
Use baseURL
to be able to work with absolute imports relative to the defined base:
./tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
...
./vite.config.js
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [
react(),
tsconfigPaths()
],
})
./src/views/App.tsx
import HelloWorld from 'components/HelloWorld'
import 'styles/App.css'
export default function App() {
return (
<>
<HelloWorld greeting='Hello from React Typescript' />
</>
)
}
./src/components/HelloWorld.tsx
import React from 'react'
import { iHelloWorld } from 'types/interfaces'
export default function HelloWorld({ greeting }: iHelloWorld): React.JSX.Element {
return (
<h1>
{ greeting }
</h1>
)
}
./src/types/interfaces.ts
export interface iHelloWorld {
greeting: string
}
npm run dev
Create the Rust Project
Now customize vite.config.ts
file to get the best compatibility with Tauri:
./vite.config.js
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [
react(),
tsconfigPaths()
],
// prevent vite from obscuring rust errors
clearScreen: false,
// Tauri expects a fixed port, fail if that port is not available
server: {
strictPort: true,
},
// to access the Tauri environment variables set by the CLI with information about the current target
envPrefix: ['VITE_', 'TAURI_PLATFORM', 'TAURI_ARCH', 'TAURI_FAMILY', 'TAURI_PLATFORM_VERSION', 'TAURI_PLATFORM_TYPE', 'TAURI_DEBUG'],
build: {
// Tauri uses Chromium on Windows and WebKit on macOS and Linux
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13',
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
}
})
Add the Tauri CLI to your project:
npm install --save-dev @tauri-apps/cli
And add it to your npm scripts:
./package.json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"rust": "tauri",
"preview": "vite preview"
},
To scaffold a minimal Rust project that is pre-configured to use Tauri, open a terminal and run the following command:
npm run rust init
✔ What is your app name? · rusty-react-query
✔ What should the window title be? › Rusty React Query
✔ Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created? · ../dist
✔ What is the url of your dev server? › http://localhost:5173
✔ What is your frontend dev command? · npm run dev
✔ What is your frontend build command? › npm run build
Now you can run the following command in your terminal to start a development build of your app:
npm run rust dev
React Query
Building the Elasticsearch Index
I am using the following Elasticsearch mapping. Simply edit it according to your API and copy it into Kibana to create the index:
PUT /cgi_interface_v0
{
"settings": {
"analysis": {
"analyzer": {
"custom_analyzer_en": {
"type": "custom",
"char_filter": [
"symbol",
"html_strip"
],
"tokenizer": "punctuation",
"filter": [
"lowercase",
"word_delimiter",
"english_stop",
"english_stemmer"
]
},
"custom_analyzer_ger": {
"type": "custom",
"char_filter": [
"symbol",
"html_strip"
],
"tokenizer": "punctuation",
"filter": [
"lowercase",
"word_delimiter",
"german_stop",
"german_stemmer"
]
}
},
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_ "
},
"english_stemmer": {
"type": "stemmer",
"language": "english"
},
"german_stop": {
"type": "stop",
"stopwords": "_german_ "
},
"german_stemmer": {
"type": "stemmer",
"language": "german"
}
},
"tokenizer": {
"punctuation": {
"type": "pattern",
"pattern": "[.,!?&=_:;']"
}
},
"char_filter": {
"symbol": {
"type": "mapping",
"mappings": [
"& => and",
":) => happy",
":( => unhappy",
"+ => plus"
]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "custom_analyzer_en",
"index": "true"
},
"cgi": {
"type": "text",
"analyzer": "custom_analyzer_en",
"index": "true"
},
"permissions": {
"properties": {
"get": {
"index": "true",
"type": "keyword"
},
"set": {
"index": "true",
"type": "keyword"
}
}
},
"series": {
"type": "text",
"index": "true",
"fields": {
"raw": {
"type": "keyword"
}
}
},
"type": {
"type": "text",
"index": "true",
"fields": {
"raw": {
"type": "keyword"
}
}
},
"date_created": {
"type": "date"
},
"date_modified": {
"type": "date"
},
"description.en": {
"type": "text",
"analyzer": "custom_analyzer_en",
"index": "true"
},
"description.de": {
"type": "text",
"analyzer": "custom_analyzer_ger",
"index": "true"
},
"abstract.en": {
"type": "text",
"analyzer": "custom_analyzer_en",
"index": "true"
},
"abstract.de": {
"type": "text",
"analyzer": "custom_analyzer_ger",
"index": "true"
},
"chapter.en": {
"type": "text",
"index": "true",
"fields": {
"raw": {
"type": "keyword"
}
}
},
"chapter.de": {
"type": "text",
"index": "true",
"fields": {
"raw": {
"type": "keyword"
}
}
},
"link.en": {
"type": "text",
"index": "false"
},
"link.de": {
"type": "text",
"index": "false"
},
"tags": {
"type": "text",
"index": "true",
"fields": {
"raw": {
"type": "keyword"
}
}
},
"image": {
"type": "text",
"index": "false"
},
"imagesquare": {
"type": "text",
"index": "false"
}
}
}
}
With the index in place you can now add entries like:
PUT /cgi_interface_v0/_doc/1440_getnetattr
{
"title": "netattr",
"series": ["1440p"],
"type": ["wifi", "lan", "poe", "pt", "ptz"],
"cgi" : "/param.cgi?cmd=getnetattr",
"date_created":"2023-09-28",
"date_modified": "2023-09-28",
"permissions": {
"get" : ["setSystem", "onvifAll", "internal"],
"set" : ["setSystem", "onvifAll", "internal"]
},
"abstract": {
"en" : "Camera Network Configuration.",
"de" : "Konfiguration des Kameranetzwerkeinstellungen."
},
"chapter": {
"en" : "Network IP Configuration",
"de" : "Netzwerk IP Konfiguration"
},
"link": {
"en" : "/en/1440p_Series_CGI_List/Network_Menu/IP_Configuration/",
"de" : "/de/1440p_Serie_CGI_Befehle/Netzwerk_Menu/IP_Konfiguration/"
},
"tags": ["dhcpflag", "ip", "netmask", "gateway", "dnsstat", "fdnsip", "sdnsip", "macaddress", "networktype", "wifi"],
"image": "/en/images/Search/AU_SearchThumb_CGICommands_1440p.webp",
"imagesquare": "/en/images/Search/TOC_Icons/Wiki_Tiles_Advanced_CGIs-1440p_white.webp",
"description": {
"en" : "Camera Network Configuration.",
"de" : "Konfiguration des Kameranetzwerkeinstellungen."
},
"parameters": [
{
"param": "dhcpflag",
"val": "[0,1]",
"description": {
"en": "on: (DHCP enabled), off: (DHCP disabled).",
"de": "ein: (DHCP aktiviert), aus: (DHCP deaktiviert)."
},
"cgi": "/param.cgi?cmd=setnetattr&dhcpflag=1",
"mqtt": "network/config/dhcp"
},
{
"param": "ip",
"val": "[192.168.178.21]",
"description": {
"en": "Current LAN IPv4 Address.",
"de": "Aktuelle LAN IPv4 Adresse."
},
"cgi": "/param.cgi?cmd=setnetattr&ip=192.168.178.21",
"mqtt": "network/config/dhcp"
},
{
"param": "netmask",
"val": "[255.255.255.0]",
"description": {
"en": "LAN Subnet Mask.",
"de": "LAN-Subnetzmaske."
},
"cgi": "/param.cgi?cmd=setnetattr&netmask=255.255.255.0",
"mqtt": "network/config/netmask"
},
{
"param": "gateway",
"val": "[192.168.178.1]",
"description": {
"en": "LAN Gateway.",
"de": "LAN Gateway."
},
"cgi": "/param.cgi?cmd=setnetattr&gateway=192.168.178.1",
"mqtt": "network/config/gateway"
},
{
"param": "dnsstat",
"val": "[0,1]",
"description": {
"en": "DNS Status: 0 (manually), 1 (from DHCP Server).",
"de": "DNS-Status: 0 (manuell), 1 (vom DHCP-Server)."
},
"cgi": "/param.cgi?cmd=setnetattr&dnsstat=1",
"mqtt": "network/config/dnsstat"
},
{
"param": "fdnsip",
"val": "[1,2]",
"description": {
"en": "Primary DNS.",
"de": "Primärer DNS Server."
},
"cgi": "/param.cgi?cmd=setnetattr&fdnsip=1",
"mqtt": "network/config/fdnsip"
},
{
"param": "sdnsip",
"val": "[1,2]",
"description": {
"en": "Secondary DNS.",
"de": "Sekundärer DNS Server."
},
"cgi": "/param.cgi?cmd=setnetattr&sdnsip=1",
"mqtt": "network/config/sdnsip"
},
{
"param": "macaddress",
"val": "[EA:6D:C8:9C:DF:A7]",
"description": {
"en": "LAN MAC Address.",
"de": "LAN MAC Adresse."
},
"cgi": "-",
"mqtt": "network/config/macaddress"
},
{
"param": "wifi",
"val": "[0, 1]",
"description": {
"en": "Enable / Disable the WiFi module.",
"de": "Aktivieren/Deaktivieren Sie das WiFi-Modul."
},
"cgi": "/param.cgi?cmd=setnetattr&wifi=1",
"mqtt": "network/config/wifi"
},
{
"param": "networktype",
"val": "[LAN, WLAN]",
"description": {
"en": "LAN or WiFi - Indicates Type of current connection.",
"de": "LAN oder WiFi - Zeigt den Typ der aktuellen Verbindung an."
},
"cgi": "-",
"mqtt": "network/config/macaddress"
}
]
}
Note that the index is versioned cgi_interface_v0
. You can assign an alias to the active version with:
PUT /cgi_interface_v0/_alias/cgi_interface
And transfer it to future versions to keep your Elasticsearch API immutable:
POST _aliases
{
"actions": [
{
"remove": {
"index": "cgi_interface_v0",
"alias": "cgi_interface"
}
},
{
"add": {
"index": "cgi_interface_v1",
"alias": "cgi_interface"
}
}
]
}
You can test the API using curl
:
curl 'https://my.elasticsearch.server/cgi_interface/_search?q=network'
With that in place let's try to fetch this data in React and embed it in an user interface.
Hardcoded Elasticsearch Query
./src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from 'views/App.tsx'
import 'styles/index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 15
},
mutations: {}
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={ queryClient }>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
Let's start by sending a hardcoded search query to the Elasticsearch API, e.g. getalarmattr
:
./src/views/App.tsx
import HelloWorld from 'components/HelloWorld'
import CGIDoc from 'components/FetchDoc'
import 'styles/App.css'
const query = 'getalarmattr'
console.log('App: '+ query)
export default function App() {
return (
<>
<HelloWorld greeting='Hello from React Typescript' />
<CGIDoc query={query} />
</>
)
}
./src/components/FetchDoc.tsx
import React from 'react'
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { iElasticSearchQuery, iElasticSearchResponse } from 'types/interfaces'
const apiUrl = 'https://my.elastic.server/cgi_interface/_search?q='
const getData = async({query}: iElasticSearchQuery): Promise<iElasticSearchResponse> => {
const response = await fetch(apiUrl+query)
console.log(apiUrl+query)
if (response.ok) {
return response.json()
}
throw new Error('ERROR :: Data fetching failed!')
}
// function Debug(query: string) {
// console.log('second: ' + query)
// return query
// }
export default function CGIDoc({query}: iElasticSearchQuery): React.JSX.Element {
// const queryClient = useQueryClient()
const {
isLoading,
isError,
error,
data,
isSuccess
}: UseQueryResult<iElasticSearchResponse, Error> = useQuery<iElasticSearchResponse, Error>({
queryKey: ['mydata'],
queryFn: () => getData({query})
// debug
// queryFn: () => Debug(query),
})
if(isLoading) return <h3>loading...</h3>
if(isError) return <p>{error.message}</p>
if(isSuccess) return (
<div>
{
data?.hits.hits.map((result) => {
return(
<div key={result._id}>
<li><strong>Title: </strong>{result._source.abstract.en}</li>
<li><strong>Score: </strong>{result._score}</li>
<li><strong>GET CGI: </strong>{result._source.cgi}</li>
{result?._source.parameters.map(param => (
<ul key={param.mqtt}>
<li><strong>SET CGI: </strong>{param.cgi}</li>
<li><strong>MQTT: </strong>{param.mqtt}</li>
</ul>
))}
<hr/>
</div>
)
})
}
</div>
)
else return <h3>...something very strange just happened...</h3>
}
Elasticsearch Query Interface
Now that we can contact Elasticsearch and retrieve a JSON object in return for a search query let's replace the hardcoded query with an text input.
First, I will remove the props from the App component - as the search query input will be part of the Fetch component:
./src/views/App.tsx
import HelloWorld from 'components/HelloWorld'
import CGIDoc from 'components/FetchDoc'
import 'styles/App.css'
export default function App(): JSX.Element {
return (
<>
<HelloWorld greeting='Hello from React Typescript' />
<CGIDoc />
</>
)
}
The Fetch component now takes in the value of the input field to run the search request:
./src/components/FetchDoc.tsx
import React, { useState } from 'react'
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { useDebounce } from 'utils/hooks'
import { api } from '../../config.ts'
import { iElasticSearchResponse } from 'types/interfaces'
const getData = async(query: string): Promise<iElasticSearchResponse> => {
// console.log(api.url+query)
const response = await fetch(api.url+query)
if (response.ok) {
return response.json()
}
throw new Error('ERROR :: Data fetching failed!')
}
export default function CGIDoc(): React.JSX.Element {
const [search, setSearch] = useState('⁇')
const debounceSearch = useDebounce(search, 500)
const {
isLoading,
isError,
error,
data,
isSuccess
}: UseQueryResult<iElasticSearchResponse, Error> = useQuery<iElasticSearchResponse, Error>({
queryKey: ['elasticresponse', { debounceSearch }],
queryFn: () => getData(debounceSearch),
staleTime: 1000 * 5,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: false,
retry: true,
retryOnMount: true,
// retryDelay: 1000 * 5, // default increases exponentially
})
if(isLoading) return <p>loading...</p>
if(isError) return <p>{error.message}</p>
if(isSuccess) return (
<div>
<input
type='text'
onChange={e => (setSearch(e.target.value))}
value={search}
placeholder='Search'
/>
{
data?.hits.hits.map((result) => {
return(
<div key={result._id}>
<li><strong>Title: </strong>{result._source.abstract.en}</li>
<li><strong>Score: </strong>{result._score}</li>
<li><strong>GET CGI: </strong>{result._source.cgi}</li>
{result?._source.parameters.map(param => (
<ul key={param.mqtt}>
<li><strong>SET CGI: </strong>{param.cgi}</li>
<li><strong>MQTT: </strong>{param.mqtt}</li>
</ul>
))}
<hr/>
</div>
)
})
}
</div>
)
else return <h3>...something very strange happened...</h3>
}
Here I am now importing the API url from an separate configuration file:
../config.ts
import { iElasticApiSearchUrl } from './src/types/interfaces'
export const api: iElasticApiSearchUrl = {
url: 'https://my.elastic.server/cgi_interface/_search?q='
}
Since my Elasticsearch cluster will serve different indexes I will also add a type interface to make sure that only valid API endpoints can be specified and we get the sweet Typescript autocompletion:
./src/types/interfaces.ts
export interface iElasticApiSearchUrl {
url: 'https://my.elastic.server/cgi_interface/_search?q=' | 'https://my.elastic.server/fw_changelog/_search?q='
}
ShadCN & Tailwind
Making things pretty:
yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
./tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
Note: replace all imports relative to ./src
by adding the @/
prefix!
./vite.config.ts
import path from "path"
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react-swc'
export default defineConfig({
plugins: [
react(),
tsconfigPaths()
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
Run the shadcn-ui init command to setup your project:
npx shadcn-ui@latest init
✔ Would you like to use TypeScript (recommended) › yes
✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? … src/styles/index.css
✔ Would you like to use CSS variables for colors? › yes
✔ Are you using a custom tailwind prefix eg. tw-? (Leave blank if not) …
✔ Where is your tailwind.config.js located? › tailwind.config.js
✔ Configure the import alias for components: › @/components
✔ Configure the import alias for utils: › @/utils
✔ Are you using React Server Components? › no
✔ Write configuration to components.json. Proceed? › yes
Now we can import our first ShadCN Component:
npx shadcn-ui@latest add input
And replace the default input using the ShadCN component:
import { Input } from "@/components/ui/input"
...
<Input
type='text'
onChange={e => (setSearch(e.target.value))}
value={search}
placeholder='Search'
/>
And a few minutes later...
Tauri Bundler
The Tauri Bundler is a Rust harness to compile your binary, package assets, and prepare a final bundle.
It will detect your operating system and build a bundle accordingly. It currently supports:
- Windows:
-setup.exe
,.msi
- macOS:
.app
,.dmg
- Linux:
.deb
,.appimage
Linux Bundle
Tauri applications for Linux are distributed either with a Debian bundle (.deb file) or an AppImage (.AppImage file). The Tauri CLI automatically bundles your application code in these formats by default. Please note that .deb and .AppImage bundles can only be created on Linux as cross-compilation doesn't work yet.
npm run rust build
> Error You must change the bundle identifier in `tauri.conf.json > tauri > bundle > identifier`. The default value `com.tauri.dev` is not allowed as it must be unique across applications.
identifier
: The application identifier in reverse domain name notation (e.g. com.tauri.example). This string must be unique across applications since it is used in system configurations like the bundle ID and path to the webview data directory. This string must contain only alphanumeric characters (A–Z, a–z, and 0–9), hyphens (-), and periods (.).
./src-tauri/tauri.conf.json
"bundle": {
...
"identifier": "com.instar.dev",
...
npm run rust build
Finished 2 bundles at:
./src-tauri/target/release/bundle/deb/rusty-react-query_0.1.0_amd64.deb
./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage
chmod +x ./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage
./src-tauri/target/release/bundle/appimage/rusty-react-query_0.1.0_amd64.AppImage