feat: frontend responding to backend SSE and refetching images

This commit is contained in:
2025-04-26 20:32:01 +01:00
parent 78a28dee8d
commit d34805030f
13 changed files with 163 additions and 75 deletions

View File

@ -12,7 +12,9 @@ import "github.com/go-jet/jet/v2/postgres"
var Progress = &struct { var Progress = &struct {
NotStarted postgres.StringExpression NotStarted postgres.StringExpression
InProgress postgres.StringExpression InProgress postgres.StringExpression
Complete postgres.StringExpression
}{ }{
NotStarted: postgres.NewEnumValue("not-started"), NotStarted: postgres.NewEnumValue("not-started"),
InProgress: postgres.NewEnumValue("in-progress"), InProgress: postgres.NewEnumValue("in-progress"),
Complete: postgres.NewEnumValue("complete"),
} }

View File

@ -14,11 +14,13 @@ type Progress string
const ( const (
Progress_NotStarted Progress = "not-started" Progress_NotStarted Progress = "not-started"
Progress_InProgress Progress = "in-progress" Progress_InProgress Progress = "in-progress"
Progress_Complete Progress = "complete"
) )
var ProgressAllValues = []Progress{ var ProgressAllValues = []Progress{
Progress_NotStarted, Progress_NotStarted,
Progress_InProgress, Progress_InProgress,
Progress_Complete,
} }
func (e *Progress) Scan(value interface{}) error { func (e *Progress) Scan(value interface{}) error {
@ -37,6 +39,8 @@ func (e *Progress) Scan(value interface{}) error {
*e = Progress_NotStarted *e = Progress_NotStarted
case "in-progress": case "in-progress":
*e = Progress_InProgress *e = Progress_InProgress
case "complete":
*e = Progress_Complete
default: default:
return errors.New("jet: Invalid scan value '" + enumValue + "' for Progress enum") return errors.New("jet: Invalid scan value '" + enumValue + "' for Progress enum")
} }

View File

@ -68,11 +68,11 @@ func ListenNewImageEvents(db *sql.DB, eventManager *EventManager) {
orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image) orchestrator.RunAgent(image.UserID, image.ImageID, image.Image.ImageName, image.Image.Image)
_, err = imageModel.FinishProcessing(ctx, image.ID) _, err = imageModel.FinishProcessing(ctx, image.ID)
if err != nil { if err != nil {
databaseEventLog.Error("Failed to finish processing", "ImageID", imageId) databaseEventLog.Error("Failed to finish processing", "ImageID", imageId, "error", err)
return 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...") logger.Info("Sending...")
imageListener <- status imageListener <- status
// close(imageListener) if status != "complete" {
// delete(eventManager.listeners, stringUuid) continue
}
close(imageListener)
delete(eventManager.listeners, stringUuid)
} }
} }
} }

View File

@ -13,7 +13,6 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@ -267,6 +266,8 @@ func main() {
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.(http.Flusher).Flush() w.(http.Flusher).Flush()
// TODO: get the current status of the image and send it across.
ctx, cancel := context.WithCancel(r.Context()) ctx, cancel := context.WithCancel(r.Context())
for { for {
@ -277,11 +278,18 @@ func main() {
cancel() cancel()
return return
case data := <-imageNotifier: case data := <-imageNotifier:
fmt.Printf("Status received: %s\n", data) if data == "" {
fmt.Fprintf(w, "event: data\ndata: %s-%s\n\n", data, time.Now().String())
w.(http.Flusher).Flush()
cancel() cancel()
return continue
}
fmt.Printf("Status received: %s\n", data)
fmt.Fprintf(w, "event: data\ndata: %s\n\n", data)
w.(http.Flusher).Flush()
if data == "complete" {
cancel()
}
} }
} }
}) })

View File

@ -117,6 +117,19 @@ func (m ImageModel) FinishProcessing(ctx context.Context, imageId uuid.UUID) (mo
return model.UserImages{}, err 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. removeProcessingStmt := UserImagesToProcess.
DELETE(). DELETE().
WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID))) WHERE(UserImagesToProcess.ID.EQ(UUID(imageToProcess.ID)))

View File

@ -4,7 +4,7 @@ CREATE SCHEMA haystack;
/* -----| Enums |----- */ /* -----| Enums |----- */
CREATE TYPE haystack.progress AS ENUM('not-started','in-progress'); CREATE TYPE haystack.progress AS ENUM('not-started','in-progress', 'complete');
/* -----| Schema tables |----- */ /* -----| Schema tables |----- */

View File

@ -7,6 +7,7 @@ permissions = [
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus",
"fs:default", "fs:default",
{ identifier = "http:default", allow = [ { identifier = "http:default", allow = [
{ url = "https://haystack.johncosta.tech" }, { url = "https://haystack.johncosta.tech" },

View File

@ -6,10 +6,10 @@ import { ProtectedRoute } from "./ProtectedRoute";
import { Search } from "./Search"; import { Search } from "./Search";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { ImageViewer } from "./components/ImageViewer"; import { ImageViewer } from "./components/ImageViewer";
import { ShareTarget } from "./components/share-target/ShareTarget";
import type { sendImage } from "./network"; import type { sendImage } from "./network";
import { ImageStatus } from "./components/image-status/ImageStatus"; import { ImageStatus } from "./components/image-status/ImageStatus";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { SearchImageContextProvider } from "./contexts/SearchImageContext";
export const App = () => { export const App = () => {
const [processingImage, setProcessingImage] = const [processingImage, setProcessingImage] =
@ -57,7 +57,7 @@ export const App = () => {
}; };
return ( return (
<> <SearchImageContextProvider>
<button type="button" onClick={onTakeScreenshot}> <button type="button" onClick={onTakeScreenshot}>
Take Screenshot [wayland :(] Take Screenshot [wayland :(]
</button> </button>
@ -71,6 +71,6 @@ export const App = () => {
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
</Route> </Route>
</Router> </Router>
</> </SearchImageContextProvider>
); );
}; };

View File

@ -1,52 +1,21 @@
import { Button } from "@kobalte/core/button"; import { Button } from "@kobalte/core/button";
import { IconSearch, IconSettings } from "@tabler/icons-solidjs"; import { IconSearch, IconSettings } from "@tabler/icons-solidjs";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { import {
For, For,
Show, Show,
createEffect, createEffect,
createResource,
createSignal, createSignal,
onCleanup, onCleanup,
onMount, onMount,
} from "solid-js"; } from "solid-js";
import { SearchCard } from "./components/search-card/SearchCard"; import { SearchCard } from "./components/search-card/SearchCard";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { ItemModal } from "./components/item-modal/ItemModal"; import { ItemModal } from "./components/item-modal/ItemModal";
import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor"; import type { Shortcut } from "./components/shortcuts/hooks/useShortcutEditor";
import { type UserImage, getUserImages } from "./network"; import type { UserImage } from "./network";
import { useSearchImageContext } from "./contexts/SearchImageContext";
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
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);
};
export const Search = () => { export const Search = () => {
const [searchResults, setSearchResults] = createSignal<UserImage[]>([]); const [searchResults, setSearchResults] = createSignal<UserImage[]>([]);
@ -55,17 +24,9 @@ export const Search = () => {
null, null,
); );
const [data] = createResource(() => const { images } = useSearchImageContext();
getUserImages().then((data) => {
console.log("DBG: ", data);
return data.map((d) => ({
...d,
rawData: getAllValues(d),
}));
}),
);
let fuze = new Fuse<UserImage>(data() ?? [], { let fuze = new Fuse<UserImage>(images() ?? [], {
keys: [ keys: [
{ name: "rawData", weight: 1 }, { name: "rawData", weight: 1 },
{ name: "title", weight: 1 }, { name: "title", weight: 1 },
@ -74,10 +35,10 @@ export const Search = () => {
}); });
createEffect(() => { createEffect(() => {
console.log("DBG: ", data()); console.log("DBG: ", images());
setSearchResults(data() ?? []); setSearchResults(images() ?? []);
fuze = new Fuse<UserImage>(data() ?? [], { fuze = new Fuse<UserImage>(images() ?? [], {
keys: [ keys: [
{ name: "data.Name", weight: 2 }, { name: "data.Name", weight: 2 },
{ name: "rawData", weight: 1 }, { name: "rawData", weight: 1 },

View File

@ -29,7 +29,6 @@ export const ImageViewer: Component<ImageViewerProps> = (props) => {
props.onSendImage(result); props.onSendImage(result);
window.location.reload();
console.log("DBG: ", result); console.log("DBG: ", result);
}); });

View File

@ -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 type { sendImage } from "../../network";
import { useSearchImageContext } from "../../contexts/SearchImageContext";
type ImageStatusProps = { type ImageStatusProps = {
processingImage: Accessor< processingImage: Accessor<
@ -7,7 +8,38 @@ type ImageStatusProps = {
>; >;
}; };
type EventData = "in-progress" | "complete";
export const ImageStatus: Component<ImageStatusProps> = (props) => { export const ImageStatus: Component<ImageStatusProps> = (props) => {
const { onRefetchImages } = useSearchImageContext();
const onEvent = (e: MessageEvent<EventData>) => {
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 ( return (
<Show when={props.processingImage()}> <Show when={props.processingImage()}>
{(image) => ( {(image) => (

View File

@ -0,0 +1,79 @@
import {
createContext,
type Resource,
type Component,
type ParentProps,
createResource,
useContext,
} from "solid-js";
import { getUserImages } from "../network";
type ImageWithRawData = Awaited<ReturnType<typeof getUserImages>>[number] & {
rawData: string[];
};
type SearchImageStore = {
images: Resource<ImageWithRawData[]>;
onRefetchImages: () => void;
};
// How wonderfully functional
const getAllValues = (object: object): Array<string> => {
const loop = (acc: Array<string>, next: object): Array<string> => {
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<SearchImageStore>();
export const SearchImageContextProvider: Component<ParentProps> = (props) => {
const [images, { refetch }] = createResource(() =>
getUserImages().then((data) => {
return data.map((d) => ({
...d,
rawData: getAllValues(d),
}));
}),
);
return (
<SearchImageContext.Provider
value={{
images,
onRefetchImages: refetch,
}}
>
{props.children}
</SearchImageContext.Provider>
);
};
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;
};

View File

@ -3,19 +3,4 @@ import { render } from "solid-js/web";
import "./index.css"; import "./index.css";
import { App } from "./App"; 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(() => <App />, document.getElementById("root") as HTMLElement); render(() => <App />, document.getElementById("root") as HTMLElement);