Skip to main content

Building a Native Elasticsearch Client in React

TST, Hongkong

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

Tanstack React Query Tauri App

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

Tanstack React Query Tauri App

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

Tanstack React Query Tauri App

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='
}

Tanstack React Query Tauri App

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

Tanstack React Query Native App

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