├── .gitignore ├── .dockerignore ├── web ├── src │ ├── vite-env.d.ts │ ├── types.tsx │ ├── datetime.ts │ ├── data │ │ ├── timeseries.ts │ │ ├── sql.ts │ │ ├── constants.ts │ │ ├── sqlgen.ts │ │ ├── client.ts │ │ ├── models │ │ │ └── useUpdateCityList.ts │ │ ├── recoil.ts │ │ └── offers.ts │ ├── index.d.ts │ ├── components │ │ ├── customcomponents │ │ │ ├── loader │ │ │ │ ├── centering-wrapper.scss │ │ │ │ ├── CenteringWrapper.tsx │ │ │ │ └── loader.scss │ │ │ └── Button.tsx │ │ ├── CodeBlock.tsx │ │ ├── SetupDatabaseButton.tsx │ │ ├── ConfigInput.tsx │ │ ├── ScaleFactorSelector.tsx │ │ ├── OfferMap.tsx │ │ ├── Footer.tsx │ │ ├── GithubButtons.tsx │ │ ├── dataConfigForm │ │ │ ├── DatabaseConfigFormAutomatic.tsx │ │ │ └── DatabaseConfigFormManual.tsx │ │ ├── IconLinks.tsx │ │ ├── Stats.tsx │ │ ├── theme.ts │ │ ├── ErrorHandler.tsx │ │ ├── ResetSchemaButton.tsx │ │ ├── EnableSimulatorButton.tsx │ │ ├── IngestChart.tsx │ │ ├── HeatMap.tsx │ │ ├── NeedHelpModal.tsx │ │ └── navBar │ │ │ └── Nav.tsx │ ├── rand.ts │ ├── format.ts │ ├── events.ts │ ├── analytics.ts │ ├── view │ │ └── hooks │ │ │ ├── useSimulationMonitor.ts │ │ │ ├── useSession.ts │ │ │ └── useSimulator.ts │ ├── assets │ │ ├── singlestore-logo-dark.svg │ │ ├── singlestore-logo-filled-sm.svg │ │ └── singlestore-logo-holo.svg │ ├── geo.ts │ ├── scalefactors.ts │ ├── main.tsx │ ├── pages │ │ └── HomePage.tsx │ ├── render │ │ └── useNotificationsRenderer.ts │ └── App.tsx ├── .gitignore ├── .prettierrc ├── postcss.config.js ├── public │ ├── socialPostLinkedIn.png │ └── 404.html ├── tsconfig.json ├── vite.config.ts ├── package.json ├── index.html └── .eslintrc.js ├── doc └── architecture.png ├── .vscode ├── tasks.json └── launch.json ├── output ├── encoder.go ├── extension.go ├── writer.go ├── writer_test.go ├── blob.go ├── mock_test.go ├── json.go └── parquet.go ├── gen ├── state.go ├── subscribers.go ├── update.go └── fill.go ├── Dockerfile ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── build-image.yml │ └── on-push.yml ├── data ├── vendors_test.go └── vendors.go ├── sql ├── pipelines.sql ├── seed.sql ├── procedures.sql ├── functions.sql └── schema.sql ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── util └── rand.go ├── go.mod ├── cmd └── simulator │ └── main.go ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | credentials.sql 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | web/node_modules 2 | web/dist 3 | Dockerfile -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /doc/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/singlestore-labs/demo-realtime-digital-marketing/main/doc/architecture.png -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: false 6 | } 7 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | plugins: { 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/src/types.tsx: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | analytics: SegmentAnalytics.AnalyticsJS; 4 | } 5 | } 6 | 7 | export {}; 8 | -------------------------------------------------------------------------------- /web/public/socialPostLinkedIn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/singlestore-labs/demo-realtime-digital-marketing/main/web/public/socialPostLinkedIn.png -------------------------------------------------------------------------------- /web/src/datetime.ts: -------------------------------------------------------------------------------- 1 | export const toISOStringNoTZ = (date: Date) => 2 | // SingleStore doesn't support timezones in date formats 3 | // so we strip the last character (Z) 4 | date.toISOString().slice(0, -1); 5 | -------------------------------------------------------------------------------- /web/src/data/timeseries.ts: -------------------------------------------------------------------------------- 1 | export type TimeseriesPoint = [Date, number]; 2 | export type Timeseries = Array; 3 | 4 | export const timeseriesIsEmpty = (series: Timeseries): boolean => 5 | series.every(([, y]) => y === 0); 6 | -------------------------------------------------------------------------------- /web/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.sql" { 2 | export type SchemaObject = { 3 | kind: string; 4 | name?: string; 5 | statement: string; 6 | }; 7 | 8 | const src: Array; 9 | export default src; 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "frontend dev server", 6 | "type": "shell", 7 | "command": "cd web && yarn run dev", 8 | "problemMatcher": [] 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Launch Chrome against localhost", 8 | "url": "http://localhost:3000", 9 | "webRoot": "${workspaceFolder}" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /output/encoder.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type BatchEncoder interface { 8 | Extension() string 9 | EncodeLocations(int64, []Location, io.Writer) error 10 | EncodeRequests(int64, []Request, io.Writer) error 11 | EncodePurchases(int64, []Purchase, io.Writer) error 12 | } 13 | -------------------------------------------------------------------------------- /gen/state.go: -------------------------------------------------------------------------------- 1 | package gen 2 | 3 | import ( 4 | "subscriber-sim/util" 5 | ) 6 | 7 | type State struct { 8 | PartitionId int 9 | SeqId int64 10 | Rand util.RandWithSource 11 | Subscribers []Subscriber 12 | 13 | PurchaseProb float64 14 | RequestProb float64 15 | 16 | MinSpeed float64 17 | MaxSpeed float64 18 | } 19 | -------------------------------------------------------------------------------- /web/src/components/customcomponents/loader/centering-wrapper.scss: -------------------------------------------------------------------------------- 1 | .single-common-components-centering-wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | 7 | width: 100%; 8 | overflow-y: auto; 9 | margin-top: auto; 10 | margin-bottom: auto; 11 | 12 | &.vertical { 13 | height: 100%; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as builder 2 | WORKDIR /usr/src/app 3 | 4 | COPY web/package.json web/yarn.lock ./web/ 5 | RUN cd web && yarn install 6 | 7 | COPY . ./ 8 | RUN cd web && yarn build 9 | 10 | RUN adduser -D static 11 | 12 | FROM joseluisq/static-web-server:2 13 | 14 | COPY --from=builder /etc/passwd /etc/passwd 15 | USER static 16 | 17 | COPY --from=builder /usr/src/app/web/dist/ /public/ 18 | 19 | EXPOSE 3000 20 | ENV SERVER_FALLBACK_PAGE=/public/404.html 21 | ENV SERVER_PORT=3000 -------------------------------------------------------------------------------- /web/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Code, HTMLChakraProps, ThemingProps } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | 4 | export interface Props extends HTMLChakraProps<"code">, ThemingProps<"Code"> {} 5 | 6 | export const CodeBlock = ({ children, ...props }: Props) => ( 7 | 17 | {children} 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /output/extension.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type ExtensionGenerator func(Batch, BatchEncoder) string 9 | 10 | func NewExtensionGenerator() ExtensionGenerator { 11 | // seq ensures we don't generate two files at the exact same nanosecond 12 | seq := 0 13 | 14 | return func(batch Batch, enc BatchEncoder) string { 15 | seq++ 16 | t := time.Now().UTC() 17 | 18 | return fmt.Sprintf( 19 | ".%04d-%02d-%02d.%02d%02d%02d.%09d.%04d.%s", 20 | t.Year(), 21 | t.Month(), 22 | t.Day(), 23 | t.Hour(), 24 | t.Minute(), 25 | t.Second(), 26 | t.Nanosecond()+seq, 27 | batch.PartitionId(), 28 | enc.Extension(), 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Redirecting... 9 | 10 | 11 | 12 |

13 | Redirecting... 14 |

15 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./", 19 | "paths": { 20 | "@/*": ["./src/*"], 21 | "@/sql/*": ["../sql/*"], 22 | "@/static-data/*": ["../data/*"] 23 | } 24 | }, 25 | "include": ["./src"] 26 | } 27 | -------------------------------------------------------------------------------- /output/writer.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ungerik/go3d/float64/vec2" 7 | ) 8 | 9 | type Location struct { 10 | SubscriberId int64 11 | Offset vec2.T 12 | } 13 | 14 | type Request struct { 15 | SubscriberId int64 16 | Domain string 17 | } 18 | 19 | type Purchase struct { 20 | SubscriberId int64 21 | Vendor string 22 | } 23 | 24 | type Batch interface { 25 | PartitionId() int 26 | SeqId() int64 27 | Locations() []Location 28 | Requests() []Request 29 | Purchases() []Purchase 30 | } 31 | 32 | type Writer interface { 33 | // Write will encode and store the provided batch in a location. 34 | // The batch is guaranteed to not be reused after Write returns. 35 | Write(context.Context, Batch) error 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/build-image.yml: -------------------------------------------------------------------------------- 1 | name: build-docker-image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up QEMU 13 | uses: docker/setup-qemu-action@v2 14 | 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | 18 | - name: Login to GitHub Container Registry 19 | uses: docker/login-action@v2 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.repository_owner }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Build and push 26 | uses: docker/build-push-action@v3 27 | with: 28 | push: true 29 | tags: ghcr.io/singlestore-labs/demo-realtime-digital-marketing:latest 30 | -------------------------------------------------------------------------------- /web/src/rand.ts: -------------------------------------------------------------------------------- 1 | import { least } from "d3-array"; 2 | 3 | import VENDORS from "@/static-data/vendors.json"; 4 | 5 | export type Vendor = (typeof VENDORS)[number]; 6 | 7 | export const randomChoice = (arr: ReadonlyArray): T => 8 | arr[Math.floor(Math.random() * arr.length)]; 9 | 10 | export const randomFloatInRange = (min: number, max: number) => 11 | Math.random() * (max - min) + min; 12 | 13 | export const randomIntegerInRange = (min: number, max: number) => 14 | Math.floor(randomFloatInRange(min, max)); 15 | 16 | export const randomVendor = (): Vendor => { 17 | const lastVendor = VENDORS[VENDORS.length - 1]; 18 | const r = randomIntegerInRange(0, lastVendor.cdf); 19 | const out = least(VENDORS, (v) => 20 | v.cdf >= r ? v.cdf - r : Number.MAX_SAFE_INTEGER 21 | ); 22 | if (out) { 23 | return out; 24 | } 25 | return lastVendor; 26 | }; 27 | -------------------------------------------------------------------------------- /web/src/components/customcomponents/loader/CenteringWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@chakra-ui/react"; 2 | import classnames from "classnames"; 3 | import * as React from "react"; 4 | 5 | import "@/components/customcomponents/loader/centering-wrapper.scss"; 6 | 7 | type Props = { 8 | className?: string; 9 | vertical?: boolean; 10 | children: React.ReactNode; 11 | }; 12 | 13 | export const CenteringWrapper = ({ className, vertical, children }: Props) => { 14 | const classes = classnames( 15 | "single-common-components-centering-wrapper", 16 | "single-common-components-centering-wrapper.vertical", 17 | className, 18 | { 19 | vertical, 20 | } 21 | ); 22 | 23 | return ( 24 | 30 | {children} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /web/src/components/SetupDatabaseButton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, useColorModeValue } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { useRecoilState } from "recoil"; 4 | 5 | import { ResetSchemaButton } from "@/components/ResetSchemaButton"; 6 | import { connectionDatabase } from "@/data/recoil"; 7 | 8 | export const SetupDatabaseButton: React.FC = () => { 9 | const [databaseName] = useRecoilState(connectionDatabase); 10 | 11 | return ( 12 | 13 | 14 | You don't have {databaseName} database. Please setup the schema for this 15 | application. 16 | 17 |
18 | 23 | Setup Database 24 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /gen/subscribers.go: -------------------------------------------------------------------------------- 1 | package gen 2 | 3 | import ( 4 | "subscriber-sim/util" 5 | 6 | "github.com/ungerik/go3d/float64/vec2" 7 | ) 8 | 9 | type Subscriber struct { 10 | Id int64 11 | 12 | Location vec2.T 13 | Velocity vec2.T 14 | TargetLocation vec2.T 15 | 16 | LastRequestDomain string 17 | LastPurchaseVendor string 18 | } 19 | 20 | func InitSubscribers(state *State, count int) { 21 | state.Subscribers = make([]Subscriber, count) 22 | offset := count * state.PartitionId 23 | 24 | for i := 0; i < count; i++ { 25 | loc := util.SampleUnitCircle(state.Rand) 26 | tgt := util.SampleUnitCircle(state.Rand) 27 | velocity := util.RandomVelocity( 28 | state.Rand, 29 | &loc, 30 | &tgt, 31 | state.MinSpeed, 32 | state.MaxSpeed, 33 | ) 34 | 35 | state.Subscribers[i] = Subscriber{ 36 | Id: int64(offset + i), 37 | Location: loc, 38 | Velocity: velocity, 39 | TargetLocation: tgt, 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/src/format.ts: -------------------------------------------------------------------------------- 1 | const SECOND_IN_MS = 1000; 2 | const MINUTE_IN_MS = 60 * SECOND_IN_MS; 3 | const HOUR_IN_MS = 60 * MINUTE_IN_MS; 4 | const DAY_IN_MS = 24 * HOUR_IN_MS; 5 | 6 | export const formatNumber = (num: number): string => 7 | num.toLocaleString(undefined, { maximumFractionDigits: 2 }); 8 | 9 | // formatMs converts a number of milliseconds to a string of the form "XhXXmXXsXXms" 10 | export const formatMs = (ms?: number): string => { 11 | if (!ms) { 12 | return "0s"; 13 | } 14 | if (ms < SECOND_IN_MS) { 15 | return `${formatNumber(ms)}ms`; 16 | } 17 | if (ms < MINUTE_IN_MS) { 18 | return `${formatNumber(ms / SECOND_IN_MS)}s`; 19 | } 20 | if (ms < HOUR_IN_MS) { 21 | return `${Math.floor(ms / MINUTE_IN_MS)}m${formatMs(ms % MINUTE_IN_MS)}`; 22 | } 23 | if (ms < DAY_IN_MS) { 24 | return `${Math.floor(ms / HOUR_IN_MS)}h${formatMs(ms % HOUR_IN_MS)}`; 25 | } 26 | return `${Math.floor(ms / DAY_IN_MS)}d${formatMs(ms % DAY_IN_MS)}`; 27 | }; 28 | -------------------------------------------------------------------------------- /output/writer_test.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "subscriber-sim/util" 8 | "testing" 9 | 10 | "gocloud.dev/blob" 11 | _ "gocloud.dev/blob/memblob" 12 | ) 13 | 14 | func TestBlobWriter(t *testing.T) { 15 | rnd := util.NewRandGen(0) 16 | genExt := NewExtensionGenerator() 17 | 18 | ctx := context.Background() 19 | b, err := blob.OpenBucket(ctx, "mem://") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | defer b.Close() 24 | 25 | jsonWriter := NewBlobWriter(b, &JSONEncoder{}) 26 | 27 | batch := NewMockBatch(rnd, 0, 1000) 28 | 29 | err = jsonWriter.Write(ctx, batch, genExt) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | iter := b.List(nil) 35 | count := 0 36 | for { 37 | obj, err := iter.Next(ctx) 38 | if err == io.EOF { 39 | break 40 | } 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | fmt.Println(obj.Key) 46 | count++ 47 | } 48 | 49 | if count != 3 { 50 | t.Fatalf("expected 3 files, got %d", count) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /data/vendors_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | _ "embed" 5 | "math/rand" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func TestChooseVendor(t *testing.T) { 11 | type args struct { 12 | rnd *rand.Rand 13 | } 14 | tests := []struct { 15 | name string 16 | args args 17 | want *Vendor 18 | }{ 19 | {"verify that ChooseVendor returns a random vendor", args{rand.New(rand.NewSource(1234))}, &Vendor{ 20 | Id: 403, 21 | Name: "Twitterwire", 22 | Domain: "twitterwire.gov", 23 | Category: "books", 24 | CDF: 216760, 25 | }}, 26 | {"different seed different vendor", args{rand.New(rand.NewSource(5))}, &Vendor{ 27 | Id: 675, 28 | Name: "InnoZ", 29 | Domain: "innoz.com", 30 | Category: "tools", 31 | CDF: 218500, 32 | }}, 33 | } 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | if got := ChooseVendor(tt.args.rnd); !reflect.DeepEqual(got, tt.want) { 37 | t.Errorf("ChooseVendor() = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web/src/components/customcomponents/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, useColorModeValue } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | 4 | // To make the primary button consistant everywhere we will not allow user to change color theme. 5 | // To have custom theme button we can directly use Button component. 6 | type customButtonProps = Omit< 7 | ButtonProps, 8 | "color" | "background" | "colorSchema" 9 | >; 10 | 11 | export const PrimaryButton = (props: customButtonProps) => { 12 | return ( 13 | 107 | window.location.reload()} 109 | size="sm" 110 | leftIcon={} 111 | > 112 | Reload 113 | 114 | 115 | 116 | 117 | ); 118 | } 119 | 120 | return <>{this.props.children}; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /web/src/components/ResetSchemaButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogBody, 4 | AlertDialogContent, 5 | AlertDialogFooter, 6 | AlertDialogHeader, 7 | AlertDialogOverlay, 8 | Button, 9 | ButtonOptions, 10 | HTMLChakraProps, 11 | ThemingProps, 12 | useBoolean, 13 | useColorModeValue, 14 | useDisclosure, 15 | } from "@chakra-ui/react"; 16 | import * as React from "react"; 17 | import { useRecoilValue } from "recoil"; 18 | 19 | import { Loader } from "@/components/customcomponents/loader/Loader"; 20 | import { useUpdateCityList } from "@/data/models/useUpdateCityList"; 21 | import { connectionDatabase } from "@/data/recoil"; 22 | import { 23 | useConnectionState, 24 | useMountedCallback, 25 | useResetSchema, 26 | } from "@/view/hooks/hooks"; 27 | 28 | import { PrimaryButton } from "./customcomponents/Button"; 29 | 30 | export type Props = HTMLChakraProps<"button"> & 31 | ButtonOptions & 32 | ThemingProps<"Button"> & { 33 | skipSeedData?: boolean; 34 | resetDataOnly?: boolean; 35 | }; 36 | 37 | export const ResetSchemaButton = (props: Props) => { 38 | const { updateCityList } = useUpdateCityList(); 39 | const { connected, initialized } = useConnectionState(); 40 | const { onOpen, onClose, isOpen } = useDisclosure(); 41 | const [resettingSchema, resettingSchemaCtrl] = useBoolean(); 42 | const database = useRecoilValue(connectionDatabase); 43 | const cancelResetSchemaBtn = React.useRef(null); 44 | const { skipSeedData, resetDataOnly, disabled, ...restProps } = props; 45 | 46 | let resetButtonContent: React.ReactNode = "Create Database"; 47 | if (resettingSchema) { 48 | resetButtonContent = ; 49 | } else if (initialized) { 50 | resetButtonContent = "Recreate database"; 51 | } 52 | 53 | const onResetSchema = useResetSchema({ 54 | before: React.useCallback( 55 | () => resettingSchemaCtrl.on(), 56 | [resettingSchemaCtrl] 57 | ), 58 | after: useMountedCallback(() => { 59 | resettingSchemaCtrl.off(); 60 | updateCityList(); 61 | onClose(); 62 | }, [onClose, resettingSchemaCtrl]), 63 | includeSeedData: !skipSeedData, 64 | resetDataOnly: !!resetDataOnly, 65 | }); 66 | 67 | return ( 68 | <> 69 | 102 | 107 | {resetButtonContent} 108 | 109 | 110 | 111 | 112 | 113 | 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /web/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Grid, 4 | GridItem, 5 | Heading, 6 | Image, 7 | Link, 8 | Stack, 9 | Tab, 10 | TabList, 11 | TabPanel, 12 | TabPanels, 13 | Tabs, 14 | Text, 15 | useColorModeValue, 16 | useMediaQuery, 17 | } from "@chakra-ui/react"; 18 | import * as React from "react"; 19 | import { useNavigate } from "react-router-dom"; 20 | 21 | import DashboardControllerImage from "@/assets/dashboard-controller-snapshot.svg"; 22 | import GraphicalBackground2 from "@/assets/graphical-background-2.svg"; 23 | import SingleStoreLogoDark from "@/assets/singlestore-logo-dark.svg"; 24 | import SinglestoreLogo from "@/assets/singlestore-logo-filled-sm.svg"; 25 | import { DatabaseConfigFormManual } from "@/components/dataConfigForm/DatabaseConfigFormManual"; 26 | import { NeedHelpModal } from "@/components/NeedHelpModal"; 27 | import { useUpdateCityList } from "@/data/models/useUpdateCityList"; 28 | import { useConnectionState } from "@/view/hooks/hooks"; 29 | 30 | const ConnectSection: React.FC = () => { 31 | const fontColor = useColorModeValue("#553ACF", "#CCC3F9"); 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | Real-Time Digital Marketing 40 | 41 | 42 | Watch{" "} 43 | 48 | SingleStoreDB 49 | {" "} 50 | serve ads to millions of simulated subscribers based on their behavior, 51 | purchases, request history, and geolocation. 52 | 53 | 54 | 55 | 67 | Connect to SinglestoreDB 68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 |
76 |
77 |
78 |
79 | ); 80 | }; 81 | 82 | export const HomePage: React.FC = () => { 83 | const { connected } = useConnectionState(); 84 | const navigate = useNavigate(); 85 | const [isSmallScreen] = useMediaQuery("(max-width: 640px)"); 86 | const { updateCityList } = useUpdateCityList(); 87 | 88 | React.useEffect(() => { 89 | if (connected) { 90 | updateCityList(); 91 | navigate("/dashboard"); 92 | } 93 | }, [connected, navigate, updateCityList]); 94 | 95 | return ( 96 | 103 | 104 | 105 | 106 | 117 | 123 | 124 | 125 | 126 | 127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /sql/procedures.sql: -------------------------------------------------------------------------------- 1 | DELIMITER // 2 | 3 | CREATE OR REPLACE PROCEDURE process_locations ( 4 | _batch QUERY( 5 | subscriber_id BIGINT NOT NULL, 6 | offset_x DOUBLE NOT NULL, 7 | offset_y DOUBLE NOT NULL 8 | ) 9 | ) 10 | AS 11 | DECLARE 12 | _expanded QUERY(city_id BIGINT, subscriber_id BIGINT, lonlat GEOGRAPHYPOINT) = SELECT 13 | city_id, subscriber_id, 14 | GEOGRAPHY_POINT( 15 | GEOGRAPHY_LONGITUDE(center) + (offset_x * diameter), 16 | GEOGRAPHY_LATITUDE(center) + (offset_y * diameter) 17 | ) AS lonlat 18 | FROM _batch, cities; 19 | BEGIN 20 | INSERT INTO subscribers (city_id, subscriber_id, current_location) 21 | SELECT city_id, subscriber_id, lonlat 22 | FROM _expanded 23 | ON DUPLICATE KEY UPDATE current_location = VALUES(current_location); 24 | 25 | INSERT INTO locations (city_id, subscriber_id, ts, lonlat, olc_8) 26 | SELECT 27 | city_id, 28 | subscriber_id, 29 | now(6) AS ts, 30 | lonlat, 31 | encode_open_location_code(lonlat, 8) AS olc_8 32 | FROM _expanded; 33 | END // 34 | 35 | CREATE OR REPLACE PROCEDURE process_requests ( 36 | _batch QUERY(subscriber_id BIGINT NOT NULL, domain TEXT NOT NULL) 37 | ) 38 | AS 39 | BEGIN 40 | INSERT INTO requests (city_id, subscriber_id, ts, domain) 41 | SELECT city_id, subscriber_id, now(6) AS ts, domain 42 | FROM _batch, cities; 43 | END // 44 | 45 | CREATE OR REPLACE PROCEDURE process_purchases ( 46 | _batch QUERY(subscriber_id BIGINT NOT NULL, vendor TEXT NOT NULL) 47 | ) 48 | AS 49 | BEGIN 50 | INSERT INTO purchases (city_id, subscriber_id, ts, vendor) 51 | SELECT city_id, subscriber_id, now(6) AS ts, vendor 52 | FROM _batch, cities; 53 | END // 54 | 55 | CREATE OR REPLACE PROCEDURE run_matching_process ( 56 | _interval ENUM("second", "minute", "hour", "day", "week", "month") 57 | ) RETURNS BIGINT 58 | AS 59 | DECLARE 60 | _ts DATETIME = NOW(6); 61 | _count BIGINT; 62 | BEGIN 63 | INSERT INTO notifications SELECT _ts, * FROM match_offers_to_subscribers(_interval); 64 | 65 | _count = row_count(); 66 | 67 | INSERT INTO subscribers_last_notification 68 | SELECT city_id, subscriber_id, ts 69 | FROM notifications 70 | WHERE ts = _ts 71 | ON DUPLICATE KEY UPDATE last_notification = _ts; 72 | 73 | RETURN _count; 74 | END // 75 | 76 | CREATE OR REPLACE PROCEDURE update_segments ( 77 | _since DATETIME(6), _until DATETIME(6) 78 | ) AS BEGIN 79 | INSERT INTO subscriber_segments 80 | SELECT * FROM dynamic_subscriber_segments(_since, _until) 81 | ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at); 82 | END // 83 | 84 | CREATE OR REPLACE PROCEDURE prune_segments ( 85 | _until DATETIME(6) 86 | ) AS BEGIN 87 | DELETE FROM subscriber_segments WHERE expires_at <= _until; 88 | END // 89 | 90 | CREATE OR REPLACE PROCEDURE update_sessions ( 91 | _session_id TEXT, _lease_duration_sections INT 92 | ) AS DECLARE 93 | _num_alive_controllers QUERY(c INT) = 94 | SELECT COUNT(*) FROM sessions 95 | WHERE is_controller AND expires_at > NOW(6); 96 | 97 | _num_transactions QUERY(i INT) = SELECT @@trancount; 98 | BEGIN 99 | -- make sure this session exists 100 | INSERT INTO sessions 101 | SET 102 | session_id = _session_id, 103 | expires_at = NOW() + INTERVAL _lease_duration_sections SECOND 104 | ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at); 105 | 106 | START TRANSACTION; 107 | 108 | -- ensure this session is the only controller if no other alive controllers are present 109 | IF SCALAR(_num_alive_controllers) = 0 THEN 110 | UPDATE sessions 111 | SET is_controller = (session_id = _session_id); 112 | END IF; 113 | 114 | -- echo the session details to the caller 115 | ECHO SELECT 116 | session_id, is_controller, expires_at 117 | FROM sessions 118 | WHERE session_id = _session_id; 119 | 120 | COMMIT; 121 | 122 | -- delete any expired sessions (with a bit of lag) 123 | DELETE FROM sessions 124 | WHERE NOW(6) > (expires_at + INTERVAL (_lease_duration_sections * 2) SECOND); 125 | 126 | EXCEPTION 127 | WHEN OTHERS THEN 128 | IF SCALAR(_num_transactions) > 0 THEN 129 | ROLLBACK; 130 | END IF; 131 | END // 132 | 133 | DELIMITER ; -------------------------------------------------------------------------------- /sql/functions.sql: -------------------------------------------------------------------------------- 1 | DELIMITER // 2 | 3 | CREATE OR REPLACE FUNCTION date_sub_dynamic ( 4 | _dt DATETIME(6), 5 | _interval ENUM("second", "minute", "hour", "day", "week", "month") 6 | ) RETURNS DATETIME(6) AS 7 | BEGIN 8 | RETURN CASE _interval 9 | WHEN "second" THEN _dt - INTERVAL 1 SECOND 10 | WHEN "minute" THEN _dt - INTERVAL 1 MINUTE 11 | WHEN "hour" THEN _dt - INTERVAL 1 HOUR 12 | WHEN "day" THEN _dt - INTERVAL 1 DAY 13 | WHEN "week" THEN _dt - INTERVAL 1 WEEK 14 | WHEN "month" THEN _dt - INTERVAL 1 MONTH 15 | END; 16 | END // 17 | 18 | CREATE OR REPLACE FUNCTION date_add_dynamic ( 19 | _dt DATETIME(6), 20 | _interval ENUM("second", "minute", "hour", "day", "week", "month") 21 | ) RETURNS DATETIME(6) AS 22 | BEGIN 23 | RETURN CASE _interval 24 | WHEN "second" THEN _dt + INTERVAL 1 SECOND 25 | WHEN "minute" THEN _dt + INTERVAL 1 MINUTE 26 | WHEN "hour" THEN _dt + INTERVAL 1 HOUR 27 | WHEN "day" THEN _dt + INTERVAL 1 DAY 28 | WHEN "week" THEN _dt + INTERVAL 1 WEEK 29 | WHEN "month" THEN _dt + INTERVAL 1 MONTH 30 | END; 31 | END // 32 | 33 | CREATE OR REPLACE FUNCTION encode_open_location_code ( 34 | _lonlat GEOGRAPHYPOINT, 35 | codeLength INT DEFAULT 12 36 | ) RETURNS TEXT AS 37 | DECLARE 38 | SEPARATOR_ text = '+'; 39 | SEPARATOR_POSITION_ int = 8; 40 | PADDING_CHARACTER_ text = '0'; 41 | CODE_ALPHABET_ text = '23456789CFGHJMPQRVWX'; 42 | ENCODING_BASE_ int = CHARACTER_LENGTH(CODE_ALPHABET_); 43 | LATITUDE_MAX_ int = 90; 44 | LONGITUDE_MAX_ int = 180; 45 | MAX_DIGIT_COUNT_ int = 15; 46 | PAIR_CODE_LENGTH_ int = 10; 47 | PAIR_PRECISION_ decimal = POWER(ENCODING_BASE_, 3); 48 | GRID_CODE_LENGTH_ int = MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_; 49 | GRID_COLUMNS_ int = 4; 50 | GRID_ROWS_ int = 5; 51 | FINAL_LAT_PRECISION_ decimal = PAIR_PRECISION_ * POWER(GRID_ROWS_, MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_); 52 | FINAL_LNG_PRECISION_ decimal = PAIR_PRECISION_ * POWER(GRID_COLUMNS_, MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_); 53 | latitude double = geography_latitude(_lonlat); 54 | longitude double = geography_longitude(_lonlat); 55 | code text = ''; 56 | latVal decimal = 0; 57 | lngVal decimal = 0; 58 | latDigit smallint; 59 | lngDigit smallint; 60 | ndx smallint; 61 | i_ smallint; 62 | BEGIN 63 | -- This function has been ported from: 64 | -- http://github.com/google/open-location-code/blob/cafb35c0d74dd0c06b6a75c05f89e32a972b7b23/plpgsql/pluscode_functions.sql#L313 65 | -- Licensed under the Apache License, Version 2.0 (the 'License') 66 | 67 | IF ((codeLength < 2) OR ((codeLength < PAIR_CODE_LENGTH_) AND (codeLength % 2 = 1)) OR (codeLength > MAX_DIGIT_COUNT_)) THEN 68 | RAISE USER_EXCEPTION(CONCAT('Invalid Open Location Code length - ', codeLength)); 69 | END IF; 70 | 71 | latVal = floor(round((latitude + LATITUDE_MAX_) * FINAL_LAT_PRECISION_, 6)); 72 | lngVal = floor(round((longitude + LONGITUDE_MAX_) * FINAL_LNG_PRECISION_, 6)); 73 | 74 | IF (codeLength > PAIR_CODE_LENGTH_) THEN 75 | i_ = 0; 76 | WHILE (i_ < (MAX_DIGIT_COUNT_ - PAIR_CODE_LENGTH_)) LOOP 77 | latDigit = latVal % GRID_ROWS_; 78 | lngDigit = lngVal % GRID_COLUMNS_; 79 | ndx = (latDigit * GRID_COLUMNS_) + lngDigit; 80 | code = concat(substr(CODE_ALPHABET_, ndx + 1, 1), code); 81 | latVal = latVal DIV GRID_ROWS_; 82 | lngVal = lngVal DIV GRID_COLUMNS_; 83 | i_ = i_ + 1; 84 | END LOOP; 85 | ELSE 86 | latVal = latVal DIV power(GRID_ROWS_, GRID_CODE_LENGTH_); 87 | lngVal = lngVal DIV power(GRID_COLUMNS_, GRID_CODE_LENGTH_); 88 | END IF; 89 | 90 | i_ = 0; 91 | WHILE (i_ < (PAIR_CODE_LENGTH_ / 2)) LOOP 92 | code = concat(substr(CODE_ALPHABET_, (lngVal % ENCODING_BASE_) + 1, 1), code); 93 | code = concat(substr(CODE_ALPHABET_, (latVal % ENCODING_BASE_) + 1, 1), code); 94 | latVal = latVal DIV ENCODING_BASE_; 95 | lngVal = lngVal DIV ENCODING_BASE_; 96 | i_ = i_ + 1; 97 | END LOOP; 98 | 99 | code = concat( 100 | substr(code, 1, SEPARATOR_POSITION_), 101 | SEPARATOR_, 102 | substr(code, SEPARATOR_POSITION_ + 1) 103 | ); 104 | 105 | IF (codeLength > SEPARATOR_POSITION_) THEN 106 | RETURN substr(code, 1, codeLength+1); 107 | ELSE 108 | RETURN substr(code, 1, codeLength); 109 | END IF; 110 | END // 111 | 112 | DELIMITER ; -------------------------------------------------------------------------------- /web/src/components/EnableSimulatorButton.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, InfoIcon } from "@chakra-ui/icons"; 2 | import { 3 | Alert, 4 | AlertDescription, 5 | AlertIcon, 6 | AlertTitle, 7 | Box, 8 | Button, 9 | FormControl, 10 | FormControlProps, 11 | FormLabel, 12 | Switch, 13 | SwitchProps, 14 | Text, 15 | Tooltip, 16 | useColorModeValue, 17 | } from "@chakra-ui/react"; 18 | import * as React from "react"; 19 | import { useRecoilState, useRecoilValue } from "recoil"; 20 | 21 | import { trackAnalyticsEvent } from "@/analytics"; 22 | import { setSessionController } from "@/data/queries"; 23 | import { connectionConfig, simulatorEnabled } from "@/data/recoil"; 24 | import { useConnectionState } from "@/view/hooks/hooks"; 25 | import { useSession } from "@/view/hooks/useSession"; 26 | 27 | export const EnableSimulatorWarning = () => { 28 | return ( 29 | 30 | 31 | 32 | The simulator is disabled 33 | 34 | This application uses a simulator to generate new data like live 35 | notifications and subscribers. To experience the full power of 36 | real-time digital marketing, please enable the simulator in the nav 37 | bar. 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export const SimulatorToggler = ({ 45 | switchProps, 46 | containerProps, 47 | }: { 48 | switchProps?: SwitchProps; 49 | containerProps?: FormControlProps; 50 | }) => { 51 | const [enabled, setEnabled] = useRecoilState(simulatorEnabled); 52 | const { session, refresh: refreshSession } = useSession(); 53 | const config = useRecoilValue(connectionConfig); 54 | const { connected, initialized } = useConnectionState(); 55 | const [toggling, setToggling] = React.useState(false); 56 | 57 | const onToggleSimulator = React.useCallback(async () => { 58 | setToggling(true); 59 | const newState = !enabled; 60 | trackAnalyticsEvent("change-simulator-state", { 61 | enabled: newState, 62 | }); 63 | if (connected && initialized) { 64 | await setSessionController(config, session.sessionID, newState); 65 | } 66 | setEnabled(newState); 67 | refreshSession(); 68 | setToggling(false); 69 | }, [ 70 | config, 71 | connected, 72 | initialized, 73 | session.sessionID, 74 | enabled, 75 | refreshSession, 76 | setEnabled, 77 | ]); 78 | 79 | return ( 80 | 81 | 89 | 90 | Simulator 91 | 92 | 93 | 94 | 101 | 102 | ); 103 | }; 104 | 105 | export const SimulatorButton = () => { 106 | return ( 107 | 111 | The simulator generates live notifications and subscribers even if the 112 | application browser window is closed. Toggle off to stop new data 113 | generation or suspend cluster in SingleStoreDB portal. 114 | 115 | } 116 | hasArrow 117 | textAlign="center" 118 | > 119 | 134 | 135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Please refer to https://github.com/eslint/eslint/issues/3458#issuecomment-648719715 2 | require("@rushstack/eslint-config/patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | parser: "@typescript-eslint/parser", 6 | plugins: [ 7 | "@typescript-eslint", 8 | "react", 9 | "babel", 10 | "switch-case", 11 | "simple-import-sort", 12 | "import", 13 | "react-hooks", 14 | "unused-imports", 15 | "@singlestore/react-hooks-disable-import", 16 | ], 17 | env: { 18 | node: true, 19 | browser: true, 20 | jest: true, 21 | }, 22 | parserOptions: { 23 | sourceType: "module", 24 | emcaVersion: 6, 25 | esversion: 6, 26 | ecmaFeatures: { 27 | jsx: true, 28 | modules: true, 29 | }, 30 | }, 31 | settings: { 32 | react: { 33 | version: "16.12.0", 34 | }, 35 | }, 36 | rules: { 37 | "no-undef": [0], 38 | "no-debugger": "error", 39 | "no-constant-condition": [0], 40 | "no-console": [0], 41 | "no-irregular-whitespace": [0], 42 | "object-shorthand": 2, 43 | eqeqeq: ["error"], 44 | "switch-case/newline-between-switch-case": [ 45 | "error", 46 | "always", 47 | { fallthrough: "never" }, 48 | ], 49 | "prefer-const": "error", 50 | 51 | "react/display-name": 0, 52 | "react/forbid-prop-types": 1, 53 | "react/jsx-boolean-value": 1, 54 | "react/jsx-curly-spacing": 1, 55 | "react/jsx-handler-names": 1, 56 | "react/jsx-key": 0, 57 | "react/jsx-max-props-per-line": 0, 58 | "react/jsx-no-bind": 0, 59 | "react/jsx-no-duplicate-props": 1, 60 | "react/jsx-no-literals": 0, 61 | "react/jsx-no-undef": 1, 62 | "react/jsx-pascal-case": [1, { ignore: ["LSM"] }], 63 | "jsx-quotes": 1, 64 | "react/jsx-sort-props": 0, 65 | "react/jsx-uses-react": 1, 66 | "react/jsx-uses-vars": 1, 67 | "react/no-danger": 1, 68 | "react/no-deprecated": 1, 69 | "react/no-did-mount-set-state": 1, 70 | "react/no-did-update-set-state": 1, 71 | "react/no-direct-mutation-state": 1, 72 | "react/no-is-mounted": 1, 73 | "react/no-multi-comp": 0, 74 | "react/no-set-state": 0, 75 | "react/no-string-refs": 1, 76 | "react/no-unknown-property": 1, 77 | "react/prefer-es6-class": 1, 78 | "react/react-in-jsx-scope": 1, 79 | "react/self-closing-comp": 1, 80 | "react/sort-comp": 0, 81 | "react/jsx-wrap-multilines": 1, 82 | "react/jsx-fragments": [1, "syntax"], 83 | "react/jsx-curly-brace-presence": [2, "never"], 84 | 85 | "babel/semi": [2, "always"], 86 | 87 | "@typescript-eslint/no-unused-vars": 0, 88 | "unused-imports/no-unused-imports": 2, 89 | "unused-imports/no-unused-vars": [ 90 | 2, 91 | { 92 | vars: "all", 93 | varsIgnorePattern: "^_", 94 | args: "after-used", 95 | argsIgnorePattern: "^_", 96 | }, 97 | ], 98 | "@typescript-eslint/array-type": ["error", { default: "generic" }], 99 | 100 | "simple-import-sort/imports": [ 101 | "error", 102 | { 103 | // The default grouping, but with css files at the end. This 104 | // prevents the currently imported styling to be overwritten by 105 | // imported components styling 106 | groups: [ 107 | ["^.+\\.graphql|gql$$"], 108 | ["^\\u0000"], 109 | ["^@?\\w"], 110 | ["^[^.]"], 111 | ["^\\."], 112 | ["^.+\\.s?css$"], 113 | ], 114 | }, 115 | ], 116 | "import/no-duplicates": "error", 117 | "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks 118 | "react-hooks/exhaustive-deps": "error", // Checks effect dependencies 119 | "@singlestore/react-hooks-disable-import/react-hooks-disable-import": 120 | "error", 121 | }, 122 | }; 123 | -------------------------------------------------------------------------------- /web/src/components/IngestChart.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Text, useColorMode } from "@chakra-ui/react"; 2 | import { 3 | AnimatedLineSeries, 4 | Axis, 5 | darkTheme, 6 | lightTheme, 7 | Tooltip, 8 | XYChart, 9 | } from "@visx/xychart"; 10 | import { RenderTooltipParams } from "@visx/xychart/lib/components/Tooltip"; 11 | import { format } from "d3-format"; 12 | import * as React from "react"; 13 | import useSWR from "swr"; 14 | 15 | import { Loader } from "@/components/customcomponents/loader/Loader"; 16 | import { ConnectionConfig } from "@/data/client"; 17 | import { estimatedRowCountObj } from "@/data/queries"; 18 | import { Timeseries, TimeseriesPoint } from "@/data/timeseries"; 19 | 20 | const SI_FORMAT = format("~s"); 21 | 22 | export const useIngestChartData = ( 23 | config: ConnectionConfig, 24 | ...tables: Array 25 | ) => { 26 | type ReturnType = { [name in TableName]: Timeseries }; 27 | const emptyCache = React.useMemo( 28 | () => tables.reduce((a, n) => ({ ...a, [n]: [] }), {} as ReturnType), 29 | [tables] 30 | ); 31 | const cache = React.useRef(emptyCache); 32 | 33 | const { data } = useSWR( 34 | ["estimatedRowCount.timeseries", ...tables], 35 | async () => { 36 | const newData = await estimatedRowCountObj(config, ...tables); 37 | const now = new Date(); 38 | 39 | cache.current = tables.reduce((memo, name) => { 40 | const ts = cache.current[name]; 41 | 42 | // add new point 43 | ts.push([now, newData[name]]); 44 | 45 | // truncate to last 30 points 46 | memo[name] = ts.slice(-30); 47 | 48 | return memo; 49 | }, {} as ReturnType); 50 | 51 | return cache.current; 52 | }, 53 | { refreshInterval: 1000 } 54 | ); 55 | 56 | if (data) { 57 | return data; 58 | } 59 | 60 | return emptyCache; 61 | }; 62 | 63 | type Props = { 64 | data: { [name in TableName]: Timeseries }; 65 | yAxisLabel: string; 66 | width?: number; 67 | height: number; 68 | }; 69 | 70 | export const IngestChart = ({ 71 | data, 72 | yAxisLabel, 73 | ...props 74 | }: Props) => { 75 | const { colorMode } = useColorMode(); 76 | const tables = Object.keys(data) as Array; 77 | 78 | const renderTooltip = React.useCallback( 79 | ({ tooltipData, colorScale }: RenderTooltipParams) => { 80 | if (!colorScale || !tooltipData) { 81 | return null; 82 | } 83 | return tables 84 | .sort( 85 | (a, b) => 86 | tooltipData.datumByKey[b].datum[1] - 87 | tooltipData.datumByKey[a].datum[1] 88 | ) 89 | .map((name) => { 90 | const { datum } = tooltipData.datumByKey[name]; 91 | return ( 92 | 93 | {name}: {format(".4~s")(datum[1])} 94 | 95 | ); 96 | }); 97 | }, 98 | [tables] 99 | ); 100 | 101 | const yTickFormat = React.useCallback( 102 | (v: number) => SI_FORMAT(v).replace("G", "B"), 103 | [] 104 | ); 105 | 106 | if (tables.some((name) => data[name].length < 2)) { 107 | return ( 108 |
109 | 110 |
111 | ); 112 | } 113 | 114 | const lines = tables.map((name) => ( 115 | datum[0]} 120 | yAccessor={(datum) => datum[1]} 121 | /> 122 | )); 123 | 124 | return ( 125 | 132 | 133 | 140 | {lines} 141 | 148 | 149 | ); 150 | }; 151 | -------------------------------------------------------------------------------- /web/src/components/HeatMap.tsx: -------------------------------------------------------------------------------- 1 | import { useConst } from "@chakra-ui/react"; 2 | import * as d3color from "d3-color"; 3 | import { ScaleSequential, scaleSequential } from "d3-scale"; 4 | import { Bounds, Point } from "pigeon-maps"; 5 | import * as PIXI from "pixi.js"; 6 | import * as React from "react"; 7 | 8 | import { PixiMap, PixiMapProps, UsePixiRenderer } from "@/components/PixiMap"; 9 | import { Polygon, WKTPolygonToPolygon } from "@/geo"; 10 | import { useDebounce } from "@/view/hooks/hooks"; 11 | 12 | // convert number (range 0-1) to color (hex) 13 | export type ColorInterpolater = (t: number) => string; 14 | 15 | interface RGBInterface { 16 | rgb(): { r: number; g: number; b: number }; 17 | darker(n: number): RGBInterface; 18 | } 19 | 20 | const colorToRGBNumber = (c: RGBInterface): number => { 21 | const { r, g, b } = c.rgb(); 22 | return (r << 16) | (g << 8) | b; 23 | }; 24 | 25 | type CellConfig = { 26 | wktPolygon: string; 27 | value: number; 28 | }; 29 | 30 | class HeatmapCell extends PIXI.Container { 31 | points: Polygon; 32 | polygon: PIXI.Graphics; 33 | hovering = false; 34 | color: number; 35 | hoverColor: number; 36 | 37 | constructor( 38 | public config: CellConfig, 39 | colorScale: ScaleSequential 40 | ) { 41 | super(); 42 | this.points = WKTPolygonToPolygon(config.wktPolygon); 43 | this.polygon = new PIXI.Graphics(); 44 | this.addChild(this.polygon); 45 | 46 | // White color may not be visible due to light background of the map. 47 | // Adding extra color opacity to avoid pure white color. 48 | const color = colorScale(config.value + 0.01); 49 | this.color = colorToRGBNumber(color); 50 | this.hoverColor = colorToRGBNumber(color.darker(1)); 51 | 52 | this.polygon.interactive = true; 53 | this.polygon.on("mouseover", () => { 54 | this.hovering = true; 55 | }); 56 | this.polygon.on("mouseout", () => { 57 | this.hovering = false; 58 | }); 59 | } 60 | 61 | update(latLngToPixel: (latlng: Point) => Point) { 62 | const color = this.hovering ? this.hoverColor : this.color; 63 | this.polygon.clear(); 64 | this.polygon.lineStyle(1.5, color, 0.5); 65 | this.polygon.beginFill(color, 0.2); 66 | this.polygon.drawPolygon( 67 | this.points.flatMap(([lng, lat]) => latLngToPixel([lat, lng])) 68 | ); 69 | this.polygon.endFill(); 70 | } 71 | } 72 | 73 | type RendererProps = { 74 | useCells: (bounds: Bounds, callback: (cells: Array) => void) => void; 75 | getCellConfig: (cell: T) => CellConfig; 76 | colorInterpolater: ColorInterpolater; 77 | }; 78 | 79 | const makeUseRenderer = 80 | (props: RendererProps): UsePixiRenderer => 81 | ({ scene, latLngToPixel, bounds }) => { 82 | const debouncedBounds = useDebounce(bounds, 100); 83 | const scale = useConst(() => 84 | scaleSequential((n: number) => 85 | d3color.cubehelix(props.colorInterpolater(n)) 86 | ) 87 | ); 88 | 89 | props.useCells(debouncedBounds, (cells) => { 90 | // clear the scene 91 | scene.removeChildren(); 92 | 93 | let minValue = Infinity; 94 | let maxValue = -Infinity; 95 | 96 | const cfgs: Array = []; 97 | for (const cell of cells) { 98 | const cfg = props.getCellConfig(cell); 99 | cfgs.push(cfg); 100 | 101 | if (cfg.value < minValue) { 102 | minValue = cfg.value; 103 | } 104 | if (cfg.value > maxValue) { 105 | maxValue = cfg.value; 106 | } 107 | } 108 | 109 | // update the scale domain to match the data 110 | scale.domain([minValue, maxValue]); 111 | 112 | for (const cfg of cfgs) { 113 | scene.addChild(new HeatmapCell(cfg, scale)); 114 | } 115 | }); 116 | 117 | return { 118 | update: React.useCallback(() => { 119 | for (let i = 0; i < scene.children.length; i++) { 120 | const child = scene.children[i] as HeatmapCell; 121 | child.update(latLngToPixel); 122 | } 123 | }, [latLngToPixel, scene]), 124 | }; 125 | }; 126 | 127 | export type HeatmapProps = RendererProps & 128 | Omit, "useRenderer" | "options">; 129 | 130 | export const Heatmap = (props: HeatmapProps) => { 131 | const { useCells, getCellConfig, colorInterpolater, ...rest } = props; 132 | const useRenderer = React.useMemo( 133 | () => makeUseRenderer({ useCells, getCellConfig, colorInterpolater }), 134 | [colorInterpolater, getCellConfig, useCells] 135 | ); 136 | return ; 137 | }; 138 | -------------------------------------------------------------------------------- /web/src/data/client.ts: -------------------------------------------------------------------------------- 1 | const rectifyHostAddress = (hostAddress: string) => { 2 | if ( 3 | hostAddress.toLowerCase().startsWith("http://") || 4 | hostAddress.toLowerCase().startsWith("https://") || 5 | hostAddress === "" 6 | ) { 7 | return hostAddress; 8 | } else { 9 | return `https://${hostAddress}`; 10 | } 11 | }; 12 | 13 | export type ConnectionConfig = { 14 | host: string; 15 | user: string; 16 | password: string; 17 | database: string; 18 | ctx?: AbortController; 19 | }; 20 | 21 | export type ConnectionConfigOptionalDatabase = Omit< 22 | ConnectionConfig, 23 | "database" 24 | > & { 25 | database?: string; 26 | }; 27 | 28 | export type SQLValue = 29 | | string 30 | | number 31 | | boolean 32 | | null 33 | | { [key: string]: SQLValue } 34 | | Array; 35 | 36 | export type Row = { [key: string]: SQLValue }; 37 | 38 | const regexSQLErrorCode = /^Error (?\d+):/; 39 | 40 | const DEBUG = false; 41 | 42 | export class SQLError extends Error { 43 | code: number; 44 | sql: string; 45 | 46 | constructor(msg: string, sql: string, code?: number) { 47 | super(msg); 48 | // https://stackoverflow.com/a/41429145/65872 49 | Object.setPrototypeOf(this, SQLError.prototype); 50 | this.sql = sql; 51 | 52 | if (code) { 53 | this.code = code; 54 | } else { 55 | const matched = msg.match(regexSQLErrorCode); 56 | this.code = matched ? parseInt(matched.groups?.code || "-1", 10) : -1; 57 | } 58 | } 59 | 60 | isUnknownDatabase() { 61 | return this.code === 1049; 62 | } 63 | 64 | isDatabaseRecovering() { 65 | return this.code === 2269; 66 | } 67 | 68 | isPlanMissing() { 69 | return this.code === 1885; 70 | } 71 | } 72 | 73 | export const QueryOne = async ( 74 | config: ConnectionConfigOptionalDatabase, 75 | sql: string, 76 | ...args: Array 77 | ): Promise => { 78 | const rows = await Query(config, sql, ...args); 79 | if (rows.length !== 1) { 80 | throw new SQLError("Expected exactly one row", sql); 81 | } 82 | 83 | return rows[0]; 84 | }; 85 | 86 | export const Query = async ( 87 | config: ConnectionConfigOptionalDatabase, 88 | sql: string, 89 | ...args: Array 90 | ): Promise> => { 91 | const data = await fetchEndpoint("query/rows", config, sql, ...args); 92 | 93 | if (data.results.length !== 1) { 94 | throw new SQLError("Expected exactly one result set", sql); 95 | } 96 | 97 | return data.results[0].rows; 98 | }; 99 | 100 | export const QueryNoDb = ( 101 | config: ConnectionConfigOptionalDatabase, 102 | sql: string, 103 | ...args: Array 104 | ): Promise> => Query({ ...config, database: undefined }, sql, ...args); 105 | 106 | export const QueryTuples = async < 107 | T extends [...Array] = Array 108 | >( 109 | config: ConnectionConfigOptionalDatabase, 110 | sql: string, 111 | ...args: Array 112 | ): Promise> => { 113 | const data = await fetchEndpoint("query/tuples", config, sql, ...args); 114 | 115 | if (data.results.length !== 1) { 116 | throw new SQLError("Expected exactly one result set", sql); 117 | } 118 | 119 | return data.results[0].rows; 120 | }; 121 | 122 | export const ExecNoDb = ( 123 | config: ConnectionConfigOptionalDatabase, 124 | sql: string, 125 | ...args: Array 126 | ): Promise<{ lastInsertId: number; rowsAffected: number }> => 127 | fetchEndpoint("exec", { ...config, database: undefined }, sql, ...args); 128 | 129 | export const Exec = ( 130 | config: ConnectionConfigOptionalDatabase, 131 | sql: string, 132 | ...args: Array 133 | ): Promise<{ lastInsertId: number; rowsAffected: number }> => 134 | fetchEndpoint("exec", config, sql, ...args); 135 | 136 | const fetchEndpoint = async ( 137 | endpoint: string, 138 | config: ConnectionConfigOptionalDatabase, 139 | sql: string, 140 | ...args: Array 141 | ) => { 142 | if (DEBUG) { 143 | console.log("running query", sql, args); 144 | } 145 | 146 | const response = await fetch( 147 | `${rectifyHostAddress(config.host)}/api/v2/${endpoint}`, 148 | { 149 | method: "POST", 150 | signal: config.ctx?.signal, 151 | headers: { 152 | "Content-Type": "application/json", 153 | Authorization: `Basic ${btoa(`${config.user}:${config.password}`)}`, 154 | }, 155 | body: JSON.stringify({ sql, args, database: config.database }), 156 | } 157 | ); 158 | 159 | if (!response.ok) { 160 | throw new SQLError(await response.text(), sql); 161 | } 162 | 163 | const data = await response.json(); 164 | 165 | if (data.error) { 166 | throw new SQLError(data.error.message, sql, data.error.code); 167 | } 168 | return data; 169 | }; 170 | -------------------------------------------------------------------------------- /web/src/components/dataConfigForm/DatabaseConfigFormManual.tsx: -------------------------------------------------------------------------------- 1 | import { Box, SimpleGrid, Stack, Tooltip, useToast } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | import { useRecoilState } from "recoil"; 4 | 5 | import { ConfigInput } from "@/components/ConfigInput"; 6 | import { ScaleFactorSelector } from "@/components/ScaleFactorSelector"; 7 | import { connectToDB } from "@/data/queries"; 8 | import { 9 | connectionDatabase, 10 | connectionHost, 11 | connectionPassword, 12 | connectionUser, 13 | } from "@/data/recoil"; 14 | 15 | import { InvertedPrimaryButton } from "../customcomponents/Button"; 16 | import { Loader } from "../customcomponents/loader/Loader"; 17 | 18 | type Props = { 19 | showDatabase?: boolean; 20 | showScaleFactor?: boolean; 21 | }; 22 | 23 | export const DatabaseConfigFormManual = ({ 24 | showDatabase, 25 | showScaleFactor, 26 | }: Props) => { 27 | const toast = useToast(); 28 | const [loading, setLoading] = React.useState(false); 29 | const [host, setHost] = useRecoilState(connectionHost); 30 | const [user, setUser] = useRecoilState(connectionUser); 31 | const [password, setPassword] = useRecoilState(connectionPassword); 32 | const [database, setDatabase] = useRecoilState(connectionDatabase); 33 | 34 | const [localHost, setLocalHost] = React.useState(host); 35 | const [localUser, setLocalUser] = React.useState(user); 36 | const [localPassword, setLocalPassword] = React.useState(password); 37 | const [localDatabase, setLocalDatabase] = React.useState(database); 38 | 39 | const connect = () => { 40 | setLoading(true); 41 | const config = { 42 | host: localHost, 43 | password: localPassword, 44 | user: localUser, 45 | }; 46 | connectToDB(config).then((connected) => { 47 | setLoading(false); 48 | let database = "martech"; 49 | if (localDatabase) { 50 | database = localDatabase; 51 | } 52 | if (connected === true) { 53 | setHost(localHost); 54 | setUser(localUser); 55 | setPassword(localPassword); 56 | setDatabase(database); 57 | } else { 58 | toast({ 59 | title: "An error occured", 60 | description: `${connected.message}`, 61 | status: "error", 62 | duration: 3000, 63 | isClosable: true, 64 | }); 65 | } 66 | }); 67 | }; 68 | 69 | const connectDisabled = 70 | localHost === "" || localUser === "" || localPassword === "" || loading; 71 | 72 | let databaseInput; 73 | if (showDatabase) { 74 | databaseInput = ( 75 | 82 | ); 83 | } 84 | 85 | let scaleFactor; 86 | if (showScaleFactor) { 87 | scaleFactor = ; 88 | } 89 | 90 | let connectButtonContainer = <>Connect; 91 | if (loading) { 92 | connectButtonContainer = ( 93 | 94 | 95 |  Connecting... 96 | 97 | ); 98 | } 99 | 100 | const handleEnterKeyPress = (e: React.KeyboardEvent) => { 101 | if (e.key.toLowerCase() === "enter") { 102 | connect(); 103 | } 104 | }; 105 | 106 | return ( 107 | 108 | 116 | 117 | 125 | 133 | 134 | {databaseInput} 135 | {scaleFactor} 136 | 137 | 143 | 149 | {connectButtonContainer} 150 | 151 | 152 | 153 | ); 154 | }; 155 | -------------------------------------------------------------------------------- /web/src/render/useNotificationsRenderer.ts: -------------------------------------------------------------------------------- 1 | import { easeCubicIn, easeExp, easeLinear, easeQuadOut } from "d3-ease"; 2 | import { Point } from "pigeon-maps"; 3 | import * as PIXI from "pixi.js"; 4 | import * as React from "react"; 5 | import { useRecoilValue } from "recoil"; 6 | import useSWR from "swr"; 7 | 8 | import { trackAnalyticsEvent } from "@/analytics"; 9 | import { UsePixiRenderer } from "@/components/PixiMap"; 10 | import { City, getCities, queryNotificationsInBounds } from "@/data/queries"; 11 | import { connectionConfig } from "@/data/recoil"; 12 | import { toISOStringNoTZ } from "@/datetime"; 13 | import { useConnectionState, useDebounce } from "@/view/hooks/hooks"; 14 | 15 | const MAX_NOTIFICATIONS = 100; 16 | const REFRESH_INTERVAL = 1000; 17 | 18 | class Pulse extends PIXI.Container { 19 | static lifetime = 1.5; 20 | static markerColor = 0x553acf; 21 | static pulseColor = 0x553acf; 22 | 23 | latlng: Point; 24 | age = 0; 25 | marker: PIXI.Graphics; 26 | pulse: PIXI.Graphics; 27 | 28 | constructor(latlng: Point) { 29 | super(); 30 | 31 | this.latlng = latlng; 32 | 33 | this.marker = new PIXI.Graphics(); 34 | this.marker.beginFill(Pulse.markerColor).drawCircle(0, 0, 5).endFill(); 35 | this.addChild(this.marker); 36 | 37 | this.pulse = new PIXI.Graphics(); 38 | this.pulse.beginFill(Pulse.pulseColor); 39 | if (this.pulse.drawTorus) { 40 | this.pulse.drawTorus(0, 0, 4, 6); 41 | } 42 | this.pulse.endFill(); 43 | this.addChild(this.pulse); 44 | } 45 | 46 | update(latLngToPixel: (latlng: Point) => Point, delta: number) { 47 | this.age += delta / 60; 48 | 49 | if (this.age > Pulse.lifetime && this.parent) { 50 | this.parent.removeChild(this); 51 | return; 52 | } 53 | 54 | const t = (this.age % Pulse.lifetime) / Pulse.lifetime; 55 | 56 | const eased = easeQuadOut(t); 57 | this.pulse.scale.set(1 + eased); 58 | 59 | const cutoff = 0.4; 60 | const alphaEase = 61 | t < cutoff 62 | ? easeCubicIn(t / cutoff) 63 | : 1 - easeLinear((t - cutoff) / (1 - cutoff)); 64 | this.pulse.alpha = alphaEase; 65 | 66 | if (t < cutoff) { 67 | this.marker.alpha = alphaEase; 68 | } else { 69 | this.marker.alpha = 1 - easeExp((t - cutoff) / (1 - cutoff)); 70 | } 71 | 72 | const [x, y] = latLngToPixel(this.latlng); 73 | this.x = x; 74 | this.y = y; 75 | } 76 | } 77 | 78 | export const useNotificationsDataKey = () => { 79 | const config = useRecoilValue(connectionConfig); 80 | const { initialized } = useConnectionState(); 81 | return React.useMemo( 82 | () => ["notifications", config, initialized], 83 | [config, initialized] 84 | ); 85 | }; 86 | 87 | export const useCities = (onSuccess: (cities: Array) => void) => { 88 | const config = useRecoilValue(connectionConfig); 89 | const { initialized } = useConnectionState(); 90 | return useSWR(["cities", config, initialized], () => getCities(config), { 91 | isPaused: () => !initialized, 92 | onSuccess, 93 | }); 94 | }; 95 | 96 | export const useNotificationsRenderer: UsePixiRenderer = ({ 97 | scene, 98 | latLngToPixel, 99 | bounds, 100 | }) => { 101 | const timestampCursor = React.useRef(toISOStringNoTZ(new Date())); 102 | const config = useRecoilValue(connectionConfig); 103 | const { initialized } = useConnectionState(); 104 | const debouncedBounds = useDebounce(bounds, 50); 105 | const swrKey = useNotificationsDataKey(); 106 | const trackedNotifications = React.useRef(false); 107 | 108 | useSWR( 109 | swrKey, 110 | () => 111 | queryNotificationsInBounds( 112 | config, 113 | timestampCursor.current, 114 | MAX_NOTIFICATIONS, 115 | debouncedBounds 116 | ), 117 | { 118 | refreshInterval: REFRESH_INTERVAL, 119 | isPaused: () => !initialized, 120 | onSuccess: (newNotifications) => { 121 | if (newNotifications.length > 0) { 122 | // we just want to log new notications once to avoid a lot of noise 123 | if (!trackedNotifications.current) { 124 | trackAnalyticsEvent("new-notifications"); 125 | trackedNotifications.current = true; 126 | } 127 | timestampCursor.current = newNotifications[0][0]; 128 | 129 | for (const [, lng, lat] of newNotifications) { 130 | scene.addChild(new Pulse([lat, lng])); 131 | } 132 | } 133 | }, 134 | } 135 | ); 136 | 137 | return { 138 | update: React.useCallback( 139 | (delta) => { 140 | // iterate in reverse since Pulses remove themselves when invisible 141 | for (let i = scene.children.length - 1; i >= 0; i--) { 142 | const child = scene.children[i] as Pulse; 143 | child.update(latLngToPixel, delta); 144 | } 145 | }, 146 | [latLngToPixel, scene] 147 | ), 148 | }; 149 | }; 150 | -------------------------------------------------------------------------------- /web/src/components/NeedHelpModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Grid, 4 | GridItem, 5 | Icon, 6 | Image, 7 | Link, 8 | Modal, 9 | ModalBody, 10 | ModalCloseButton, 11 | ModalContent, 12 | ModalFooter, 13 | ModalHeader, 14 | ModalOverlay, 15 | Stack, 16 | Text, 17 | useColorModeValue, 18 | } from "@chakra-ui/react"; 19 | import * as React from "react"; 20 | import { HiChevronRight } from "react-icons/hi"; 21 | 22 | import CreateWorkspaceButtonSVG from "@/assets/needhelpmodal/create-workspace-button.svg"; 23 | import RTDMConnectButtonSVG from "@/assets/needhelpmodal/rtdm-connect-button.svg"; 24 | import WorkspaceConnectOptionSVG from "@/assets/needhelpmodal/workspace-connect-direct.svg"; 25 | 26 | import { InvertedPrimaryButton } from "./customcomponents/Button"; 27 | 28 | const HOW_IT_WORKS_STEPS = [ 29 | { 30 | title: "1. Enter your workspace host address.", 31 | description: ( 32 | <> 33 | Go to your SingleStoreDB workspace group page and copy 34 | SinglestoreDB endpoints by clicking in workspace Connect options. 35 | 36 | ), 37 | imageSrc: CreateWorkspaceButtonSVG, 38 | }, 39 | { 40 | title: "2. Paste Workspace Group's password", 41 | description: ( 42 | <> 43 | This can be obtained from the Connect Directly flyout. The 44 | password can be reset in the Access tab of your Workspace Group. 45 | 46 | ), 47 | imageSrc: WorkspaceConnectOptionSVG, 48 | }, 49 | { 50 | title: "3. Select Martech database and connect", 51 | description: ( 52 | <> 53 | If you already have the Martech dataset loaded, please introduce 54 | its database name. Otherwise, choose the new database name to load the 55 | dataset. 56 | 57 | ), 58 | imageSrc: RTDMConnectButtonSVG, 59 | }, 60 | ]; 61 | 62 | const StepSeparator = () => ( 63 | 69 | 76 | 77 | ); 78 | 79 | const SingleStepContainer = ({ 80 | title, 81 | description, 82 | imageSrc, 83 | }: { 84 | title: string; 85 | description: React.ReactNode; 86 | imageSrc: string; 87 | }) => ( 88 | 96 | 103 | 104 | {title} 105 | 106 | {description} 107 | 108 | ); 109 | 110 | const StepGridItems = () => { 111 | const numOfSteps = HOW_IT_WORKS_STEPS.length; 112 | 113 | const getStepSeparator = (index: number) => { 114 | // We do not add separator after the last step. 115 | if (index < numOfSteps - 1) { 116 | return ; 117 | } 118 | }; 119 | 120 | return ( 121 | <> 122 | {HOW_IT_WORKS_STEPS.map(({ title, description, imageSrc }, index) => ( 123 | 124 | 129 | {/* We add a arrow separator in between each steps.*/} 130 | {getStepSeparator(index)} 131 | 132 | ))} 133 | 134 | ); 135 | }; 136 | 137 | export const NeedHelpModal = () => { 138 | const [showModal, setShowModal] = React.useState(false); 139 | 140 | const openModal = () => setShowModal(true); 141 | const closeModal = () => setShowModal(false); 142 | 143 | return ( 144 | <> 145 | Need help? 146 | 147 | 148 | 153 | 154 | 155 | How to connect the Real-Time Digital marketing app to 156 | SingleStoreDB? 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 170 | Start now 171 | 172 | 173 | 174 | 175 | 176 | 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /web/src/data/models/useUpdateCityList.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { SetterOrUpdater, useRecoilState, useRecoilValue } from "recoil"; 3 | 4 | import { trackAnalyticsEvent } from "@/analytics"; 5 | import { ConnectionConfig } from "@/data/client"; 6 | import { createCity, removeCity } from "@/data/offers"; 7 | import { 8 | City, 9 | getCities, 10 | lookupClosestCity, 11 | seedCityWithOffers, 12 | } from "@/data/queries"; 13 | import { ScaleFactors } from "@/scalefactors"; 14 | 15 | import { 16 | connectionConfig, 17 | errorUpdatingCities, 18 | isUpdatingCities, 19 | selectedCities, 20 | selectedCity, 21 | } from "../recoil"; 22 | 23 | const getSelectedCitiesFromDatabase = async ( 24 | config: ConnectionConfig, 25 | selectCityHook: [number, SetterOrUpdater], 26 | setIsUpdating: React.Dispatch>, 27 | setCities: React.Dispatch>>, 28 | setError: React.Dispatch> 29 | ) => { 30 | const [lastSelectedCityId, setLastSelectedCityId] = selectCityHook; 31 | setIsUpdating(true); 32 | try { 33 | // Fetch cities from database in SinglestoreDB. 34 | const cities = await getCities(config); 35 | setCities(cities); 36 | 37 | // Update lastSelectedCityId in case it is not found in selected cities from database. 38 | const doesLastSelectedCityExist = cities.find( 39 | (c) => c.id === lastSelectedCityId 40 | ); 41 | if (!doesLastSelectedCityExist) { 42 | setLastSelectedCityId((cities[0] && cities[0].id) || -1); 43 | } 44 | } catch (error) { 45 | setError(error as Error); 46 | } 47 | setIsUpdating(false); 48 | }; 49 | 50 | const addCityToDatabase = async ( 51 | config: ConnectionConfig, 52 | point: [number, number], 53 | selectCityHook: [number, SetterOrUpdater], 54 | setIsUpdating: React.Dispatch>, 55 | setCities: React.Dispatch>>, 56 | setError: React.Dispatch> 57 | ) => { 58 | // The addCityToDatabase will add city along with city details in cities table from martech database. 59 | // This will also automatically update selectedCities in recoil after the new city is added. 60 | 61 | setIsUpdating(true); 62 | const city = await lookupClosestCity(config, point[1], point[0]); 63 | const cityConfig = { 64 | id: city.id, 65 | name: city.name, 66 | lonlat: <[number, number]>[city.centerLon, city.centerLat], 67 | diameter: city.diameter, 68 | }; 69 | await createCity(config, cityConfig); 70 | trackAnalyticsEvent("create-city"); 71 | await seedCityWithOffers(config, cityConfig, ScaleFactors[0]); 72 | await getSelectedCitiesFromDatabase( 73 | config, 74 | selectCityHook, 75 | setIsUpdating, 76 | setCities, 77 | setError 78 | ); 79 | setIsUpdating(false); 80 | }; 81 | 82 | const removeCityFromDatabase = async ( 83 | config: ConnectionConfig, 84 | cityId: number, 85 | selectCityHook: [number, SetterOrUpdater], 86 | setIsUpdating: React.Dispatch>, 87 | setCities: React.Dispatch>>, 88 | setError: React.Dispatch> 89 | ) => { 90 | // The removeCityFromDatabase will remove city matching cityID from cities table in martech database. 91 | // This will also automatically update selectedCities in recoil after the city is removed. 92 | 93 | setIsUpdating(true); 94 | await removeCity(config, cityId); 95 | trackAnalyticsEvent("remove-city"); 96 | await getSelectedCitiesFromDatabase( 97 | config, 98 | selectCityHook, 99 | setIsUpdating, 100 | setCities, 101 | setError 102 | ); 103 | setIsUpdating(false); 104 | }; 105 | 106 | export interface CityListHookReturnType { 107 | onCreateCity: (lat: number, lon: number) => void; 108 | onRemoveCity: (cityId: number) => void; 109 | updateCityList: () => void; 110 | } 111 | 112 | export const useUpdateCityList = (): CityListHookReturnType => { 113 | // The useUpdateCityList hook provides functionality to add or remove cities from cities database. 114 | // The RTDM will only fetch analytical data that will are related to city from the cities database. 115 | const [_selectedCities, setSelectedCities] = useRecoilState(selectedCities); 116 | const [_error, setError] = useRecoilState(errorUpdatingCities); 117 | const [_isUpdating, setIsUpdating] = useRecoilState(isUpdatingCities); 118 | const selectCityHook = useRecoilState(selectedCity); 119 | const config = useRecoilValue(connectionConfig); 120 | 121 | const onCreateCity = async (lat: number, lon: number) => { 122 | await addCityToDatabase( 123 | config, 124 | [lat, lon], 125 | selectCityHook, 126 | setIsUpdating, 127 | setSelectedCities, 128 | setError 129 | ); 130 | }; 131 | const onRemoveCity = async (cityId: number) => { 132 | await removeCityFromDatabase( 133 | config, 134 | cityId, 135 | selectCityHook, 136 | setIsUpdating, 137 | setSelectedCities, 138 | setError 139 | ); 140 | }; 141 | 142 | const updateCityList = async () => { 143 | await getSelectedCitiesFromDatabase( 144 | config, 145 | selectCityHook, 146 | setIsUpdating, 147 | setSelectedCities, 148 | setError 149 | ); 150 | }; 151 | 152 | return { 153 | onCreateCity, 154 | onRemoveCity, 155 | updateCityList, 156 | }; 157 | }; 158 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircleIcon, CloseIcon } from "@chakra-ui/icons"; 2 | import { 3 | Box, 4 | Center, 5 | Flex, 6 | Heading, 7 | Text, 8 | useColorModeValue, 9 | useToast, 10 | } from "@chakra-ui/react"; 11 | import * as React from "react"; 12 | import { Navigate, Route, Routes } from "react-router-dom"; 13 | import { useRecoilState } from "recoil"; 14 | 15 | import { useAnalytics } from "@/analytics"; 16 | import { Loader } from "@/components/customcomponents/loader/Loader"; 17 | import { Footer } from "@/components/Footer"; 18 | import { Nav } from "@/components/navBar/Nav"; 19 | import { AnalyticsDashboard } from "@/pages/Analytics"; 20 | import { Configure } from "@/pages/Configure"; 21 | import { Dashboard } from "@/pages/Dashboard"; 22 | import { HomePage } from "@/pages/HomePage"; 23 | import { useConnectionState } from "@/view/hooks/hooks"; 24 | 25 | import { showWelcomeMessage } from "./data/recoil"; 26 | 27 | const WelcomeMessageToast = () => { 28 | const [welcomeMessage, setwelcomeMessage] = 29 | useRecoilState(showWelcomeMessage); 30 | const toast = useToast(); 31 | const { connected } = useConnectionState(); 32 | const defaultFontTheme = useColorModeValue("white", "black"); 33 | 34 | const ToastDescriptionComponent = () => { 35 | return ( 36 | 37 | Hello! 38 | This is a demo application for an international marketing company 39 | serving simulated customer offers to millions of subscribers. You can: 40 |
    41 |
  • Add or remove locations from dashboard
  • 42 |
  • Inspect engagement under "Analytics"
  • 43 |
  • Change schema settings with "Configure"
  • 44 |
45 |
46 | ); 47 | }; 48 | 49 | const ToastBlock = () => ( 50 | 61 | toast.close("welcomeToast")} 68 | /> 69 | 70 | 71 | 72 | ); 73 | 74 | // Welcome message toast on successfully connecting to singlestore for the first time. 75 | if (connected && welcomeMessage) { 76 | toast({ 77 | id: "welcomeToast", 78 | duration: 9000, 79 | isClosable: true, 80 | position: "bottom", 81 | render: () => , 82 | }); 83 | } 84 | setwelcomeMessage(false); 85 | 86 | return <>; 87 | }; 88 | 89 | const PrivateRoute: React.FC<{ children: React.ReactElement }> = ({ 90 | children, 91 | }) => { 92 | // Private routes will ensure user is connected to singlestore before using the route. 93 | // Will redirect to connection page in case user in not connected. 94 | const { connected, isValidatingConnection } = useConnectionState(); 95 | 96 | if (!connected && !isValidatingConnection) { 97 | return ; 98 | } 99 | 100 | return ( 101 | 102 | 103 | {children} 104 | 105 | ); 106 | }; 107 | 108 | const Analytics = ({ children }: { children: React.ReactNode }) => { 109 | useAnalytics(); 110 | return <>{children}; 111 | }; 112 | 113 | const LayoutContainer = ({ children }: { children: React.ReactNode }) => { 114 | const { connected, isValidatingConnection } = useConnectionState(); 115 | let childComponent = ; 116 | 117 | if (connected || !isValidatingConnection) { 118 | childComponent = <>{children}; 119 | } 120 | 121 | return ( 122 | <> 123 |