From d34805030f2eeb4f796310e1b0edc7afc6760cc2 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 26 Apr 2025 20:32:01 +0100 Subject: [PATCH] feat: frontend responding to backend SSE and refetching images --- .../.gen/haystack/haystack/enum/progress.go | 2 + .../.gen/haystack/haystack/model/progress.go | 4 + backend/events.go | 12 ++- backend/main.go | 16 +++- backend/models/image.go | 13 +++ backend/schema.sql | 2 +- frontend/src-tauri/capabilities/desktop.toml | 1 + frontend/src/App.tsx | 6 +- frontend/src/Search.tsx | 53 ++----------- frontend/src/components/ImageViewer.tsx | 1 - .../components/image-status/ImageStatus.tsx | 34 +++++++- frontend/src/contexts/SearchImageContext.tsx | 79 +++++++++++++++++++ frontend/src/index.tsx | 15 ---- 13 files changed, 163 insertions(+), 75 deletions(-) create mode 100644 frontend/src/contexts/SearchImageContext.tsx diff --git a/backend/.gen/haystack/haystack/enum/progress.go b/backend/.gen/haystack/haystack/enum/progress.go index 1509f3f..c30e564 100644 --- a/backend/.gen/haystack/haystack/enum/progress.go +++ b/backend/.gen/haystack/haystack/enum/progress.go @@ -12,7 +12,9 @@ import "github.com/go-jet/jet/v2/postgres" var Progress = &struct { NotStarted postgres.StringExpression InProgress postgres.StringExpression + Complete postgres.StringExpression }{ NotStarted: postgres.NewEnumValue("not-started"), InProgress: postgres.NewEnumValue("in-progress"), + Complete: postgres.NewEnumValue("complete"), } diff --git a/backend/.gen/haystack/haystack/model/progress.go b/backend/.gen/haystack/haystack/model/progress.go index 968b5c0..c674a42 100644 --- a/backend/.gen/haystack/haystack/model/progress.go +++ b/backend/.gen/haystack/haystack/model/progress.go @@ -14,11 +14,13 @@ type Progress string const ( Progress_NotStarted Progress = "not-started" Progress_InProgress Progress = "in-progress" + Progress_Complete Progress = "complete" ) var ProgressAllValues = []Progress{ Progress_NotStarted, Progress_InProgress, + Progress_Complete, } func (e *Progress) Scan(value interface{}) error { @@ -37,6 +39,8 @@ func (e *Progress) Scan(value interface{}) error { *e = Progress_NotStarted case "in-progress": *e = Progress_InProgress + case "complete": + *e = Progress_Complete default: return errors.New("jet: Invalid scan value '" + enumValue + "' for Progress enum") } diff --git a/backend/events.go b/backend/events.go index fd23f03..6eda913 100644 --- a/backend/events.go +++ b/backend/events.go @@ -68,11 +68,11 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) { orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image) _, err = imageModel.FinishProcessing(ctx, image.ID) if err != nil { - databaseEventLog.Error("Failed to finish processing", "ImageID", imageId) + databaseEventLog.Error("Failed to finish processing", "ImageID", imageId, "error", err) return } - databaseEventLog.Debug("Starting processing image", "ImageID", imageId) + databaseEventLog.Debug("Finished processing image", "ImageID", imageId) }() } } @@ -119,8 +119,12 @@ func ListenProcessingImageStatus(db *sql.DB, eventManager *EventManager) { logger.Info("Sending...") imageListener <- status - // close(imageListener) - // delete(eventManager.listeners, stringUuid) + if status != "complete" { + continue + } + + close(imageListener) + delete(eventManager.listeners, stringUuid) } } } diff --git a/backend/main.go b/backend/main.go index 42caedc..f5190a5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -13,7 +13,6 @@ import ( "screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/agents/client" "screenmark/screenmark/models" - "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -267,6 +266,8 @@ func main() { w.Header().Set("Connection", "keep-alive") w.(http.Flusher).Flush() + // TODO: get the current status of the image and send it across. + ctx, cancel := context.WithCancel(r.Context()) for { @@ -277,11 +278,18 @@ func main() { cancel() return case data := <-imageNotifier: + if data == "" { + cancel() + continue + } + fmt.Printf("Status received: %s\n", data) - fmt.Fprintf(w, "event: data\ndata: %s-%s\n\n", data, time.Now().String()) + fmt.Fprintf(w, "event: data\ndata: %s\n\n", data) w.(http.Flusher).Flush() - cancel() - return + + if data == "complete" { + cancel() + } } } }) diff --git a/backend/models/image.go b/backend/models/image.go index e5abe31..e3daa10 100644 --- a/backend/models/image.go +++ b/backend/models/image.go @@ -117,6 +117,19 @@ func (m ImageModel) FinishProcessing(ctx context.Context, imageId uuid.UUID) (mo return model.UserImages{}, err } + // Hacky. Update the status before removing so we can get our regular triggers + // to work. + + updateStatusStmt := UserImagesToProcess. + UPDATE(UserImagesToProcess.Status). + SET(model.Progress_Complete). + WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID))) + + _, err = updateStatusStmt.ExecContext(ctx, tx) + if err != nil { + return model.UserImages{}, err + } + removeProcessingStmt := UserImagesToProcess. DELETE(). WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID))) diff --git a/backend/schema.sql b/backend/schema.sql index f074a2b..daff7e2 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -4,7 +4,7 @@ CREATE SCHEMA haystack; /* -----| Enums |----- */ -CREATE TYPE haystack.progress AS ENUM('not-started','in-progress'); +CREATE TYPE haystack.progress AS ENUM('not-started','in-progress', 'complete'); /* -----| Schema tables |----- */ diff --git a/frontend/src-tauri/capabilities/desktop.toml b/frontend/src-tauri/capabilities/desktop.toml index 9b6ac60..5243f9b 100644 --- a/frontend/src-tauri/capabilities/desktop.toml +++ b/frontend/src-tauri/capabilities/desktop.toml @@ -7,6 +7,7 @@ permissions = [ "core:default", "core:window:allow-start-dragging", "core:window:allow-show", + "core:window:allow-set-focus", "fs:default", { identifier = "http:default", allow = [ { url = "https://haystack.johncosta.tech" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 56edfa7..af704d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,10 +6,10 @@ import { ProtectedRoute } from "./ProtectedRoute"; import { Search } from "./Search"; import { Settings } from "./Settings"; import { ImageViewer } from "./components/ImageViewer"; -import { ShareTarget } from "./components/share-target/ShareTarget"; import type { sendImage } from "./network"; import { ImageStatus } from "./components/image-status/ImageStatus"; import { invoke } from "@tauri-apps/api/core"; +import { SearchImageContextProvider } from "./contexts/SearchImageContext"; export const App = () => { const [processingImage, setProcessingImage] = @@ -57,7 +57,7 @@ export const App = () => { }; return ( - <> + @@ -71,6 +71,6 @@ export const App = () => { - + ); }; diff --git a/frontend/src/Search.tsx b/frontend/src/Search.tsx index 5e95c3d..4b25edd 100644 --- a/frontend/src/Search.tsx +++ b/frontend/src/Search.tsx @@ -1,52 +1,21 @@ import { Button } from "@kobalte/core/button"; - import { IconSearch, IconSettings } from "@tabler/icons-solidjs"; import { listen } from "@tauri-apps/api/event"; - import Fuse from "fuse.js"; import { For, Show, createEffect, - createResource, createSignal, onCleanup, onMount, } from "solid-js"; - import { SearchCard } from "./components/search-card/SearchCard"; - import { invoke } from "@tauri-apps/api/core"; import { ItemModal } from "./components/item-modal/ItemModal"; import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor"; -import { type UserImage, getUserImages } from "./network"; - -// How wonderfully functional -const getAllValues = (object: object): Array => { - const loop = (acc: Array, next: object): Array => { - for (const _value of Object.values(next)) { - const value: unknown = _value; - switch (typeof value) { - case "object": - if (value != null) { - acc.push(...loop(acc, value)); - } - break; - case "string": - case "number": - case "boolean": - acc.push(value.toString()); - break; - default: - break; - } - } - - return acc; - }; - - return loop([], object); -}; +import type { UserImage } from "./network"; +import { useSearchImageContext } from "./contexts/SearchImageContext"; export const Search = () => { const [searchResults, setSearchResults] = createSignal([]); @@ -55,17 +24,9 @@ export const Search = () => { null, ); - const [data] = createResource(() => - getUserImages().then((data) => { - console.log("DBG: ", data); - return data.map((d) => ({ - ...d, - rawData: getAllValues(d), - })); - }), - ); + const { images } = useSearchImageContext(); - let fuze = new Fuse(data() ?? [], { + let fuze = new Fuse(images() ?? [], { keys: [ { name: "rawData", weight: 1 }, { name: "title", weight: 1 }, @@ -74,10 +35,10 @@ export const Search = () => { }); createEffect(() => { - console.log("DBG: ", data()); - setSearchResults(data() ?? []); + console.log("DBG: ", images()); + setSearchResults(images() ?? []); - fuze = new Fuse(data() ?? [], { + fuze = new Fuse(images() ?? [], { keys: [ { name: "data.Name", weight: 2 }, { name: "rawData", weight: 1 }, diff --git a/frontend/src/components/ImageViewer.tsx b/frontend/src/components/ImageViewer.tsx index f32939f..447a80c 100644 --- a/frontend/src/components/ImageViewer.tsx +++ b/frontend/src/components/ImageViewer.tsx @@ -29,7 +29,6 @@ export const ImageViewer: Component = (props) => { props.onSendImage(result); - window.location.reload(); console.log("DBG: ", result); }); diff --git a/frontend/src/components/image-status/ImageStatus.tsx b/frontend/src/components/image-status/ImageStatus.tsx index 4be12d9..8781936 100644 --- a/frontend/src/components/image-status/ImageStatus.tsx +++ b/frontend/src/components/image-status/ImageStatus.tsx @@ -1,5 +1,6 @@ -import { Show, type Accessor, type Component } from "solid-js"; +import { createEffect, Show, type Accessor, type Component } from "solid-js"; import type { sendImage } from "../../network"; +import { useSearchImageContext } from "../../contexts/SearchImageContext"; type ImageStatusProps = { processingImage: Accessor< @@ -7,7 +8,38 @@ type ImageStatusProps = { >; }; +type EventData = "in-progress" | "complete"; + export const ImageStatus: Component = (props) => { + const { onRefetchImages } = useSearchImageContext(); + + const onEvent = (e: MessageEvent) => { + console.log(e.data); + + if (e.data !== "complete") { + return; + } + + onRefetchImages(); + }; + + createEffect(() => { + const image = props.processingImage(); + if (image == null) { + return; + } + + const eventSource = new EventSource( + `http://192.168.1.199:3040/image-events/${image.ID}`, + ); + + eventSource.addEventListener("data", onEvent); + + return () => { + eventSource.removeEventListener("data", onEvent); + }; + }); + return ( {(image) => ( diff --git a/frontend/src/contexts/SearchImageContext.tsx b/frontend/src/contexts/SearchImageContext.tsx new file mode 100644 index 0000000..a4c0427 --- /dev/null +++ b/frontend/src/contexts/SearchImageContext.tsx @@ -0,0 +1,79 @@ +import { + createContext, + type Resource, + type Component, + type ParentProps, + createResource, + useContext, +} from "solid-js"; +import { getUserImages } from "../network"; + +type ImageWithRawData = Awaited>[number] & { + rawData: string[]; +}; + +type SearchImageStore = { + images: Resource; + onRefetchImages: () => void; +}; + +// How wonderfully functional +const getAllValues = (object: object): Array => { + const loop = (acc: Array, next: object): Array => { + for (const _value of Object.values(next)) { + const value: unknown = _value; + switch (typeof value) { + case "object": + if (value != null) { + acc.push(...loop(acc, value)); + } + break; + case "string": + case "number": + case "boolean": + acc.push(value.toString()); + break; + default: + break; + } + } + + return acc; + }; + + return loop([], object); +}; + +const SearchImageContext = createContext(); +export const SearchImageContextProvider: Component = (props) => { + const [images, { refetch }] = createResource(() => + getUserImages().then((data) => { + return data.map((d) => ({ + ...d, + rawData: getAllValues(d), + })); + }), + ); + + return ( + + {props.children} + + ); +}; + +export const useSearchImageContext = () => { + const context = useContext(SearchImageContext); + if (context == null) { + throw new Error( + "Unreachable: We should always have a mounted context and no undefined values", + ); + } + + return context; +}; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0e8f6d5..10d83e5 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -3,19 +3,4 @@ import { render } from "solid-js/web"; import "./index.css"; import { App } from "./App"; -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); -console.log("Hello android!"); - render(() => , document.getElementById("root") as HTMLElement);