feat: frontend responding to backend SSE and refetching images
This commit is contained in:
@ -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"),
|
||||||
}
|
}
|
||||||
|
@ -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")
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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)))
|
||||||
|
@ -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 |----- */
|
||||||
|
|
||||||
|
@ -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" },
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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 },
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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) => (
|
||||||
|
79
frontend/src/contexts/SearchImageContext.tsx
Normal file
79
frontend/src/contexts/SearchImageContext.tsx
Normal 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;
|
||||||
|
};
|
@ -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);
|
||||||
|
Reference in New Issue
Block a user