From 48efd69c8863f74084310b9b29c7ac166f387a79 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sun, 19 Jul 2026 21:52:59 +0200 Subject: [PATCH 1/7] initial tanstack query migration draft --- apps/backend/openapi.yaml | 127 ++++++++++ apps/web/package.json | 1 + apps/web/src/components/AuthWrapper.tsx | 53 ++--- apps/web/src/components/QueryProvider.tsx | 8 + .../src/components/auth/AuthWelcomeForm.tsx | 10 +- apps/web/src/components/auth/LoginForm.tsx | 22 +- apps/web/src/components/auth/SignupForm.tsx | 19 +- .../dashboard/UpdateDetailsDialog.tsx | 31 +-- apps/web/src/components/links/LinksLayout.tsx | 62 ++--- apps/web/src/components/news/NewsLayout.tsx | 134 ++++------- apps/web/src/context/useAuth.tsx | 26 +- apps/web/src/hooks/useApiMutation.ts | 19 ++ apps/web/src/hooks/useApiQuery.ts | 30 +++ apps/web/src/hooks/usePageConfig.ts | 67 ++---- apps/web/src/lib/api/index.ts | 4 +- apps/web/src/lib/api/sdk.gen.ts | 90 ++++++- apps/web/src/lib/api/types.gen.ts | 223 +++++++++++++++++- apps/web/src/lib/apiClient.ts | 39 +-- apps/web/src/lib/queryClient.ts | 49 ++++ apps/web/src/main.tsx | 5 +- bun.lock | 5 + 21 files changed, 725 insertions(+), 299 deletions(-) create mode 100644 apps/web/src/components/QueryProvider.tsx create mode 100644 apps/web/src/hooks/useApiMutation.ts create mode 100644 apps/web/src/hooks/useApiQuery.ts create mode 100644 apps/web/src/lib/queryClient.ts diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 0e4a1028..0a8408e3 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -403,6 +403,126 @@ paths: $ref: "#/components/responses/JsonOk" "400": $ref: "#/components/responses/JsonBadRequest" + /monitoring/ssh-hosts: + get: + tags: + - monitoring + summary: List SSH hosts + responses: + "200": + $ref: "#/components/responses/JsonOk" + post: + tags: + - monitoring + summary: Create SSH host + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/ssh-hosts/{id}: + put: + tags: + - monitoring + summary: Update SSH host + parameters: + - $ref: "#/components/parameters/Id" + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + delete: + tags: + - monitoring + summary: Delete SSH host + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/hosts: + get: + tags: + - monitoring + summary: List system-agent hosts + responses: + "200": + $ref: "#/components/responses/JsonOk" + post: + tags: + - monitoring + summary: Create system-agent host + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/hosts/{id}: + get: + tags: + - monitoring + summary: Get system-agent host + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + $ref: "#/components/responses/JsonOk" + put: + tags: + - monitoring + summary: Update system-agent host + parameters: + - $ref: "#/components/parameters/Id" + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + delete: + tags: + - monitoring + summary: Delete system-agent host + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/hosts/{id}/stats: + get: + tags: + - monitoring + summary: Get current system-agent host stats + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/hosts/{id}/history: + get: + tags: + - monitoring + summary: Get system-agent host metric history + parameters: + - $ref: "#/components/parameters/Id" + - name: timestamp + in: query + required: false + schema: + type: string + responses: + "200": + $ref: "#/components/responses/JsonOk" + /monitoring/hosts/{id}/refresh: + post: + tags: + - monitoring + summary: Refresh a system-agent host + parameters: + - $ref: "#/components/parameters/Id" + responses: + "200": + $ref: "#/components/responses/JsonOk" "401": $ref: "#/components/responses/JsonUnauthorized" /news: @@ -1181,6 +1301,13 @@ paths: "200": $ref: "#/components/responses/JsonOk" components: + parameters: + Id: + name: id + in: path + required: true + schema: + type: string schemas: GenericObject: type: object diff --git a/apps/web/package.json b/apps/web/package.json index f6bdd92d..aef32b3c 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/apps/web/src/components/AuthWrapper.tsx b/apps/web/src/components/AuthWrapper.tsx index 0034b53c..c6590097 100644 --- a/apps/web/src/components/AuthWrapper.tsx +++ b/apps/web/src/components/AuthWrapper.tsx @@ -1,6 +1,7 @@ "use client"; import { ReactNode, useEffect, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { validateAuthTokenAction } from '@/lib/apiClient'; import useAuth from "@/context/useAuth"; import { cn } from "@/lib/utils"; @@ -18,8 +19,13 @@ type ThemeMode = "light" | "dark" | "system"; export default function AuthWrapper({ children }: AuthWrapperProps) { const navigate = useNavigate(); const { token, user, setAuth, logout } = useAuth(); - const location = useLocation(); const [isMounted, setIsMounted] = useState(false); + const authValidation = useQuery({ + queryKey: ["auth", "validate", token], + enabled: Boolean(token), + retry: false, + queryFn: () => validateAuthTokenAction({ token }), + }); useEffect(() => { setIsMounted(true); @@ -32,36 +38,21 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { }, [navigate, token]); useEffect(() => { - if (!token) return; - - let cancelled = false; - - const refreshUser = async () => { - try { - const response = await validateAuthTokenAction({ token }); - if (cancelled) return; - - if (response?.user) { - setAuth(response.user, response.token ?? token); - } - } catch (error: any) { - if (cancelled) return; - - if (error?.status === 401) { - logout(); - navigate("/auth/login", { replace: true }); - } else { - console.error("Failed to refresh authenticated user:", error); - } - } - }; - - refreshUser(); + if (authValidation.data?.user) { + setAuth(authValidation.data.user, authValidation.data.token ?? token); + } + }, [authValidation.data, setAuth, token]); - return () => { - cancelled = true; - }; - }, [location.pathname, location.search, logout, navigate, setAuth, token]); + useEffect(() => { + if (!authValidation.error) return; + const status = (authValidation.error as Error & { status?: number }).status; + if (status === 401) { + logout(); + navigate("/auth/login", { replace: true }); + } else { + console.error("Failed to refresh authenticated user:", authValidation.error); + } + }, [authValidation.error, logout, navigate]); useEffect(() => { if (typeof document === "undefined" || typeof window === "undefined") return; diff --git a/apps/web/src/components/QueryProvider.tsx b/apps/web/src/components/QueryProvider.tsx new file mode 100644 index 00000000..1f746dff --- /dev/null +++ b/apps/web/src/components/QueryProvider.tsx @@ -0,0 +1,8 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { createQueryClient } from "@/lib/queryClient"; + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(createQueryClient); + return {children}; +} diff --git a/apps/web/src/components/auth/AuthWelcomeForm.tsx b/apps/web/src/components/auth/AuthWelcomeForm.tsx index b01d0ba7..8c94a726 100644 --- a/apps/web/src/components/auth/AuthWelcomeForm.tsx +++ b/apps/web/src/components/auth/AuthWelcomeForm.tsx @@ -3,18 +3,18 @@ import { useNavigate } from "react-router-dom" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import config from "@/lib/config" -import { useEffect, useState } from "react" +import { useEffect } from "react" +import { useQuery } from "@tanstack/react-query" import { getAppConfigAction } from '@/lib/apiClient'; import { validateAuthTokenAction } from '@/lib/apiClient'; +import { queryKeys } from "@/lib/queryClient"; export default function AuthWelcomeFormComponent() { const navigate = useNavigate(); - const [enableSSO, setEnableSSO] = useState(null); + const appConfigQuery = useQuery({ queryKey: queryKeys.appConfig, queryFn: getAppConfigAction }); + const enableSSO = appConfigQuery.data?.enableSSO ?? false; useEffect(() => { - // Load runtime config - getAppConfigAction().then(res => setEnableSSO(res.enableSSO ?? false)).catch(() => setEnableSSO(false)); - const validateAuth = async () => { const loginToken = new URLSearchParams(window.location.search).get("loginToken"); const token = loginToken || localStorage.getItem('pb_token'); diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx index a3ec5ef9..0cc600b7 100644 --- a/apps/web/src/components/auth/LoginForm.tsx +++ b/apps/web/src/components/auth/LoginForm.tsx @@ -2,9 +2,11 @@ import { Link, useNavigate } from "react-router-dom" import { useEffect, useState } from "react" +import { useMutation, useQuery } from "@tanstack/react-query" import { getAppConfigAction } from '@/lib/apiClient'; import { loginUserAction, validateAuthTokenAction } from '@/lib/apiClient'; import useAuth from "@/context/useAuth" +import { queryKeys } from "@/lib/queryClient"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -28,15 +30,13 @@ export default function LoginCard() { const [password, setPassword] = useState("") const [error, setError] = useState(null) const [success, setSuccess] = useState(null) - const [loading, setLoading] = useState(false) - const [enableSSO, setEnableSSO] = useState(null); + const appConfigQuery = useQuery({ queryKey: queryKeys.appConfig, queryFn: getAppConfigAction }); + const enableSSO = appConfigQuery.data?.enableSSO ?? false; + const loginMutation = useMutation({ mutationFn: loginUserAction }); //on load: check for existing auth, validate using /api/v1/auth/validate-auth endpoint if returned success to /home useEffect(() => { - // Fetch runtime config (e.g. enableSSO) - getAppConfigAction().then(data => setEnableSSO(data.enableSSO ?? false)).catch(() => setEnableSSO(false)); - const validateAuth = async () => { const tokenToCheck = token; if (!tokenToCheck) return; @@ -60,10 +60,8 @@ export default function LoginCard() { e.preventDefault(); setError(null); setSuccess(null); - setLoading(true); - try { - const { token: newToken, user } = await loginUserAction({ email, password }); + const { token: newToken, user } = await loginMutation.mutateAsync({ email, password }) as { token: string; user: import("@dashwise/types/sdk").AuthUserRecord }; setAuth(user, newToken); setSuccess("Login successful! Redirecting to home..."); @@ -75,8 +73,6 @@ export default function LoginCard() { } catch (err: any) { console.error(err); setError(err?.message || "Login failed"); - } finally { - setLoading(false); } } @@ -159,8 +155,8 @@ export default function LoginCard() { /> - @@ -178,4 +174,4 @@ export default function LoginCard() { ) -} \ No newline at end of file +} diff --git a/apps/web/src/components/auth/SignupForm.tsx b/apps/web/src/components/auth/SignupForm.tsx index 26e5b249..d4c79f13 100644 --- a/apps/web/src/components/auth/SignupForm.tsx +++ b/apps/web/src/components/auth/SignupForm.tsx @@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom" import { useEffect, useState } from "react" +import { useMutation, useQuery } from "@tanstack/react-query" import { getAppConfigAction } from '@/lib/apiClient'; import { signupUserAction, validateAuthTokenAction } from '@/lib/apiClient'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" @@ -12,6 +13,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faCircleCheck, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons" import config from "@/lib/config"; +import { queryKeys } from "@/lib/queryClient"; export default function SignupCard() { const [name, setName] = useState(""); @@ -20,15 +22,13 @@ export default function SignupCard() { const [confirmPassword, setConfirmPassword] = useState("") const [error, setError] = useState(null) const [success, setSuccess] = useState(null) - const [loading, setLoading] = useState(false) const navigate = useNavigate() - const [enableSSO, setEnableSSO] = useState(null); + const appConfigQuery = useQuery({ queryKey: queryKeys.appConfig, queryFn: getAppConfigAction }); + const enableSSO = appConfigQuery.data?.enableSSO ?? false; + const signupMutation = useMutation({ mutationFn: signupUserAction }); //on load: check for existing auth, validate using /api/v1/auth/validate-auth endpoint if returned success to /home useEffect(() => { - // Fetch runtime config (e.g. enableSSO) - getAppConfigAction().then(res => setEnableSSO(res.enableSSO ?? false)).catch(() => setEnableSSO(false)); - const validateAuth = async () => { const token = localStorage.getItem('pb_token'); if (!token) return; @@ -63,9 +63,8 @@ export default function SignupCard() { return } - setLoading(true) try { - await signupUserAction({ _name: name, email, password, passwordConfirm: confirmPassword }); + await signupMutation.mutateAsync({ _name: name, email, password, passwordConfirm: confirmPassword }); setSuccess("Redirecting to login...") setTimeout(() => { @@ -80,8 +79,6 @@ export default function SignupCard() { } catch (err) { console.error("Signup request failed:", err) setError((err as any)?.message || "Network error") - } finally { - setLoading(false) } } @@ -156,8 +153,8 @@ export default function SignupCard() { /> - diff --git a/apps/web/src/components/dashboard/UpdateDetailsDialog.tsx b/apps/web/src/components/dashboard/UpdateDetailsDialog.tsx index 07d96f2d..391a4a42 100644 --- a/apps/web/src/components/dashboard/UpdateDetailsDialog.tsx +++ b/apps/web/src/components/dashboard/UpdateDetailsDialog.tsx @@ -1,35 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; import { Dialog, DialogTrigger, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Icon } from "@iconify-icon/react"; import { getAppInfoAction } from '@/lib/apiClient'; -import useAuth from "@/context/useAuth"; +import { useApiQuery } from "@/hooks/useApiQuery"; export default function UpdateDetailsDialogComponent() { - const { withAuth } = useAuth(); - const [updateAvailable, setUpdateAvailable] = useState(false); - const [currentVersion, setCurrentVersion] = useState(""); - const [newVersion, setNewVersion] = useState(""); - - useEffect(() => { - async function fetchUpdateInfo() { - try { - const data = await withAuth((auth) => getAppInfoAction(auth)); - - if (data.updateAvailable != "0") { - setUpdateAvailable(true); - setCurrentVersion(data.currentAppVersion); - setNewVersion(data.updateAvailable ?? "unknown"); - } - } catch (err) { - console.error("Update check failed:", err); - } - } - - fetchUpdateInfo(); - }, [withAuth]); + const appInfoQuery = useApiQuery(["app-info"], async (auth) => + getAppInfoAction(auth) as Promise<{ updateAvailable?: string; currentAppVersion?: string }>, + ); + const updateAvailable = appInfoQuery.data?.updateAvailable !== undefined && appInfoQuery.data.updateAvailable !== "0"; + const currentVersion = appInfoQuery.data?.currentAppVersion ?? ""; + const newVersion = appInfoQuery.data?.updateAvailable ?? "unknown"; if (!updateAvailable) return null; // only show if update exists diff --git a/apps/web/src/components/links/LinksLayout.tsx b/apps/web/src/components/links/LinksLayout.tsx index 3607e641..0968680e 100644 --- a/apps/web/src/components/links/LinksLayout.tsx +++ b/apps/web/src/components/links/LinksLayout.tsx @@ -1,12 +1,15 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import AppTemplate, { GroupLabel, Sidebar, Tab, Content } from "@/components/apps/LayoutTemplate"; -import useAuth from "@/context/useAuth"; import { getLinksCollectionsAction, getLinksTagsAction } from '@/lib/apiClient'; import CreateLinksCollectionDialog from "@/components/links/CreateLinksCollectionDialog"; import CreateLinksTagDialog from "@/components/links/CreateLinksTagDialog"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; +import { useQueryClient } from "@tanstack/react-query"; +import useAuth from "@/context/useAuth"; type LinkCollection = { id: string; @@ -23,51 +26,18 @@ type LinkTag = { }; export default function LinksLayout({ children }: { children: ReactNode }) { - const { token, withAuth } = useAuth(); const navigate = useNavigate(); - const [collections, setCollections] = useState([]); - const [tags, setTags] = useState([]); + const queryClient = useQueryClient(); + const { token } = useAuth(); + const collectionsQuery = useApiQuery(queryKeys.links.collections, getLinksCollectionsAction); + const tagsQuery = useApiQuery(queryKeys.links.tags, getLinksTagsAction); + const collections = Array.isArray(collectionsQuery.data) ? collectionsQuery.data as LinkCollection[] : []; + const tags = Array.isArray(tagsQuery.data) ? tagsQuery.data as LinkTag[] : []; const [createListOpen, setCreateListOpen] = useState(false); const [createTagOpen, setCreateTagOpen] = useState(false); const [editingCollection, setEditingCollection] = useState(null); const [editingTag, setEditingTag] = useState(null); - useEffect(() => { - if (!token) { - setCollections([]); - setTags([]); - return; - } - - let mounted = true; - - const load = async () => { - try { - const [collectionsData, tagsData] = await Promise.all([ - withAuth((auth) => getLinksCollectionsAction(auth)), - withAuth((auth) => getLinksTagsAction(auth)), - ]); - - if (!mounted) return; - - setCollections(Array.isArray(collectionsData) ? (collectionsData as LinkCollection[]) : []); - setTags(Array.isArray(tagsData) ? (tagsData as LinkTag[]) : []); - } catch (error) { - console.error("Failed to load links navigation data:", error); - if (mounted) { - setCollections([]); - setTags([]); - } - } - }; - - load(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); - const userCollections = useMemo( () => collections.filter((collection) => { const type = String(collection.type ?? "").toLowerCase(); @@ -159,7 +129,9 @@ export default function LinksLayout({ children }: { children: ReactNode }) { }} collection={editingCollection} onSaved={(collection) => { - setCollections((current) => [collection, ...current.filter((item) => item.id !== collection.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.links.collections], (current: LinkCollection[] | undefined) => + [collection, ...(current ?? []).filter((item) => item.id !== collection.id)], + ); navigate(`/links/lists/${collection.id}`); }} /> @@ -172,10 +144,12 @@ export default function LinksLayout({ children }: { children: ReactNode }) { }} tag={editingTag} onSaved={(tag) => { - setTags((current) => [tag, ...current.filter((item) => item.id !== tag.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.links.tags], (current: LinkTag[] | undefined) => + [tag, ...(current ?? []).filter((item) => item.id !== tag.id)], + ); navigate(`/links/tags/${tag.id}`); }} /> ); -} \ No newline at end of file +} diff --git a/apps/web/src/components/news/NewsLayout.tsx b/apps/web/src/components/news/NewsLayout.tsx index ac61905c..973bf097 100644 --- a/apps/web/src/components/news/NewsLayout.tsx +++ b/apps/web/src/components/news/NewsLayout.tsx @@ -1,7 +1,7 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import useAuth from "@/context/useAuth"; import { deleteNewsSavedArticleListAction, @@ -10,6 +10,8 @@ import { getNewsSubscriptionsAction, } from '@/lib/apiClient'; import AppTemplate, { Content, GroupLabel, Sidebar, Tab } from "@/components/apps/LayoutTemplate"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryClient"; export interface Subscription { id?: string; @@ -55,94 +57,61 @@ export function useNewsSidebarData() { } export default function NewsLayout({ children }: { children: ReactNode }) { - const { token, withAuth } = useAuth(); + const { token } = useAuth(); const navigate = useNavigate(); const { feedId } = useParams(); - const [searchParams] = useSearchParams(); - const [subscriptions, setSubscriptions] = useState([]); - const [feeds, setFeeds] = useState([]); - const [savedLists, setSavedLists] = useState([]); - const [savedArticles, setSavedArticles] = useState([]); - const [sidebarRefreshVersion, setSidebarRefreshVersion] = useState(0); + const queryClient = useQueryClient(); const activeSavedList = feedId?.startsWith("saved-") ? decodeURIComponent(feedId.slice("saved-".length)) : null; - useEffect(() => { - const refreshSidebar = () => setSidebarRefreshVersion((version) => version + 1); - window.addEventListener("dashwise:news-sidebar-refresh", refreshSidebar); + const subscriptionsQuery = useQuery({ + queryKey: queryKeys.news.subscriptions(token), + enabled: Boolean(token), + queryFn: () => getNewsSubscriptionsAction({ token }), + }); + const feedsQuery = useQuery({ + queryKey: queryKeys.news.feeds(token), + enabled: Boolean(token), + queryFn: () => getNewsFeedsAction({ token }), + }); + const savedArticlesQuery = useQuery({ + queryKey: queryKeys.news.savedArticles(token, activeSavedList), + enabled: Boolean(token), + queryFn: () => getNewsSavedArticlesAction({ token }, activeSavedList), + }); + const subscriptions = useMemo( + () => (subscriptionsQuery.data?.subscriptions ?? []) as Subscription[], + [subscriptionsQuery.data], + ); + const feeds = useMemo( + () => (feedsQuery.data?.feeds ?? []) as FeedRecord[], + [feedsQuery.data], + ); + const savedLists = useMemo( + () => (savedArticlesQuery.data?.lists ?? []) as SavedListRecord[], + [savedArticlesQuery.data], + ); + const savedArticles = useMemo( + () => (savedArticlesQuery.data?.articles ?? []) as SavedArticleRecord[], + [savedArticlesQuery.data], + ); - return () => { - window.removeEventListener("dashwise:news-sidebar-refresh", refreshSidebar); - }; - }, []); + const reloadSidebar = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ["news"] }); + }, [queryClient]); useEffect(() => { - if (!token) { - setSubscriptions([]); - setFeeds([]); - return; - } - - let mounted = true; - - const loadSubscriptions = async () => { - try { - const data = await withAuth((auth) => getNewsSubscriptionsAction(auth)); - if (mounted) setSubscriptions(data?.subscriptions ?? []); - } catch (error) { - console.error("Failed to load news subscriptions:", error); - if (mounted) setSubscriptions([]); - } - }; - - const loadFeeds = async () => { - try { - const data = await withAuth((auth) => getNewsFeedsAction(auth)); - if (mounted) setFeeds(Array.isArray(data?.feeds) ? data.feeds : []); - } catch (error) { - console.error("Failed to load news feeds:", error); - if (mounted) setFeeds([]); - } - }; - - void Promise.all([loadSubscriptions(), loadFeeds()]); + const refreshSidebar = () => reloadSidebar(); + window.addEventListener("dashwise:news-sidebar-refresh", refreshSidebar); return () => { - mounted = false; - }; - }, [token, withAuth, sidebarRefreshVersion]); - - useEffect(() => { - if (!token) { - setSavedLists([]); - setSavedArticles([]); - return; - } - - let mounted = true; - - const loadSavedArticles = async () => { - try { - const savedData = await withAuth((auth) => getNewsSavedArticlesAction(auth, activeSavedList)); - - if (!mounted) return; - - setSavedLists(Array.isArray(savedData?.lists) ? savedData.lists : fallbackSavedLists); - setSavedArticles(Array.isArray(savedData?.articles) ? savedData.articles : []); - } catch (error) { - console.error("Failed to load news saved articles:", error); - if (mounted) { - setSavedLists([]); - setSavedArticles([]); - } - } + window.removeEventListener("dashwise:news-sidebar-refresh", refreshSidebar); }; + }, [reloadSidebar]); - loadSavedArticles(); - - return () => { - mounted = false; - }; - }, [token, withAuth, sidebarRefreshVersion, activeSavedList]); + const deleteSavedListMutation = useMutation({ + mutationFn: (listId: string) => deleteNewsSavedArticleListAction({ token }, listId), + onSuccess: reloadSidebar, + }); const userFeeds = useMemo(() => feeds.filter((entry) => entry.id && entry.id !== "all"), [feeds]); const savedSubscriptionRefs = useMemo(() => { @@ -177,8 +146,6 @@ export default function NewsLayout({ children }: { children: ReactNode }) { }; const activeFeedRoute = feedId ? `/apps/news/${feedId}` : "/apps/news"; - const sidebarAction = searchParams.get("action"); - const editFeedRef = searchParams.get("feed"); const openSubscribeModal = () => { const params = new URLSearchParams({ action: "subscribe" }); @@ -212,16 +179,13 @@ export default function NewsLayout({ children }: { children: ReactNode }) { const confirmed = window.confirm(`Delete ${list.name} and all saved articles in it?`); if (!confirmed) return; - await withAuth((auth) => deleteNewsSavedArticleListAction(auth, list.id)); - setSidebarRefreshVersion((version) => version + 1); + await deleteSavedListMutation.mutateAsync(list.id); if (activeSavedList === list.id || activeSavedList === list.name) { navigate("/apps/news"); } }; - const reloadSidebar = () => setSidebarRefreshVersion((version) => version + 1); - return ( diff --git a/apps/web/src/context/useAuth.tsx b/apps/web/src/context/useAuth.tsx index 5324bd65..544b710e 100644 --- a/apps/web/src/context/useAuth.tsx +++ b/apps/web/src/context/useAuth.tsx @@ -4,6 +4,7 @@ import { updateUserPropertyAction } from '@/lib/apiClient'; import type { ActionAuth, AuthUserRecord, UserPropertyValue } from "@dashwise/types/sdk"; import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; type AuthUser = AuthUserRecord; @@ -18,6 +19,7 @@ function createUnauthorizedError() { export function useAuth() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [user, setUser] = useState(() => { try { const raw = typeof window !== "undefined" ? localStorage.getItem("pb_user") : null; @@ -112,10 +114,14 @@ export function useAuth() { document.cookie = "pb_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure"; document.cookie = "pb_user=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure"; } + // The token is part of authenticated keys, but removing these entries as + // well prevents stale data accumulating across repeated account changes. + queryClient.removeQueries({ queryKey: ["api"] }); + queryClient.removeQueries({ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey.includes(token) }); } catch (err) { // ignore } - }, [setAuth]); + }, [queryClient, setAuth, token]); const withAuth = useCallback( async (fn: (auth: ActionAuth) => Promise, onUnauthorized?: () => void): Promise => { @@ -147,16 +153,16 @@ export function useAuth() { [redirectToLogin, withAuth] ); - const updateUserProperty = useCallback( - async (propertyName: string, propertyValue: UserPropertyValue) => { - const updatedUser = await withAuth((auth) => - updateUserPropertyAction(auth, propertyName, propertyValue) - ); + const updateUserPropertyMutation = useMutation({ + mutationFn: async ({ propertyName, propertyValue }: { propertyName: string; propertyValue: UserPropertyValue }) => + withAuth((auth) => updateUserPropertyAction(auth, propertyName, propertyValue)), + onSuccess: (updatedUser) => setAuth(updatedUser, token), + }); - setAuth(updatedUser, token); - return updatedUser; - }, - [token, withAuth, setAuth] + const updateUserProperty = useCallback( + (propertyName: string, propertyValue: UserPropertyValue) => + updateUserPropertyMutation.mutateAsync({ propertyName, propertyValue }), + [updateUserPropertyMutation], ); return { user, token, setAuth, setToken: setTokenOnly, logout, withAuth, withAuthRedirect, redirectToLogin, updateUserProperty }; diff --git a/apps/web/src/hooks/useApiMutation.ts b/apps/web/src/hooks/useApiMutation.ts new file mode 100644 index 00000000..6c21930c --- /dev/null +++ b/apps/web/src/hooks/useApiMutation.ts @@ -0,0 +1,19 @@ +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import useAuth from "@/context/useAuth"; +import type { ActionAuth } from "@dashwise/types/sdk"; + +/** Standard mutation path for authenticated API endpoints. */ +export function useApiMutation( + mutationFn: (auth: ActionAuth, variables: TVariables) => Promise, + options?: Omit, "mutationFn">, +) { + const { token } = useAuth(); + + return useMutation({ + ...options, + mutationFn: (variables) => { + if (!token) throw new Error("Unauthorized"); + return mutationFn({ token }, variables); + }, + }); +} diff --git a/apps/web/src/hooks/useApiQuery.ts b/apps/web/src/hooks/useApiQuery.ts new file mode 100644 index 00000000..280a6715 --- /dev/null +++ b/apps/web/src/hooks/useApiQuery.ts @@ -0,0 +1,30 @@ +import { useQuery, type QueryKey, type UseQueryOptions } from "@tanstack/react-query"; +import useAuth from "@/context/useAuth"; +import type { ActionAuth } from "@dashwise/types/sdk"; + +type AuthenticatedQueryOptions = Omit< + UseQueryOptions, + "queryKey" | "queryFn" | "enabled" +> & { + enabled?: boolean; +}; + +/** + * The standard read path for authenticated API endpoints. The session token is + * included in the cache key to prevent data from a previous session appearing + * during a user switch. + */ +export function useApiQuery( + key: readonly unknown[], + queryFn: (auth: ActionAuth) => Promise, + options?: AuthenticatedQueryOptions, +) { + const { token } = useAuth(); + + return useQuery({ + ...options, + queryKey: ["api", token, ...key], + enabled: Boolean(token) && (options?.enabled ?? true), + queryFn: () => queryFn({ token }), + }); +} diff --git a/apps/web/src/hooks/usePageConfig.ts b/apps/web/src/hooks/usePageConfig.ts index c387228f..22eeb417 100644 --- a/apps/web/src/hooks/usePageConfig.ts +++ b/apps/web/src/hooks/usePageConfig.ts @@ -2,9 +2,11 @@ import { getPageConfigAction } from '@/lib/apiClient'; import useAuth from "@/context/useAuth"; +import { queryKeys } from "@/lib/queryClient"; import type { PageConfig } from "@dashwise/types/sdk"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useCallback, useEffect, useMemo, useState, type SetStateAction } from "react"; +import { useLocation } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, type SetStateAction } from "react"; const nonPageSegments = new Set(["settings", "notifications", "onboarding", "screensaver", "auth", "frame"]); @@ -19,64 +21,47 @@ type UsePageConfigOptions = { export function usePageConfig(options?: UsePageConfigOptions) { const pathname = useLocation().pathname; - const navigate = useNavigate(); - const { token, withAuth } = useAuth(); + const { token } = useAuth(); const resolvedPageName = useMemo( () => options?.pageName ?? resolveRequestedPageName(pathname), [options?.pageName, pathname] ); - const [pageConfig, setPageConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const queryKey = useMemo(() => queryKeys.pageConfig(token, resolvedPageName), [resolvedPageName, token]); + const pageConfigQuery = useQuery({ + queryKey, + enabled: Boolean(token), + queryFn: () => getPageConfigAction({ token }, resolvedPageName), + }); + const pageConfig = pageConfigQuery.data === undefined + ? null + : (pageConfigQuery.data ?? {}) as PageConfig; const patchConfig = useCallback((updater: SetStateAction) => { - setPageConfig((current) => (typeof updater === "function" ? updater(current) : updater)); - }, []); + queryClient.setQueryData(queryKey, (current) => + typeof updater === "function" ? updater(current ?? null) : updater, + ); + }, [queryClient, queryKey]); const refreshConfig = useCallback(async () => { - if (!token) { - setPageConfig(null); - setLoading(false); - return; - } - - try { - setLoading(true); - const data = await withAuth( - (auth) => getPageConfigAction(auth, resolvedPageName), - () => navigate("/auth/login") - ); - setPageConfig((data ?? {}) as PageConfig); - setError(null); - } catch (err: unknown) { - if (typeof err === "object" && err !== null && "status" in err && (err as { status?: number }).status === 401) { - return; - } - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setLoading(false); - } - }, [navigate, resolvedPageName, token, withAuth]); - - - useEffect(() => { - void refreshConfig(); - }, [refreshConfig]); + if (!token) return; + await pageConfigQuery.refetch(); + }, [pageConfigQuery, token]); useEffect(() => { const handleUpdated = () => { - void refreshConfig(); + void queryClient.invalidateQueries({ queryKey }); }; window.addEventListener("config:updated", handleUpdated); return () => window.removeEventListener("config:updated", handleUpdated); - }, [refreshConfig]); + }, [queryClient, queryKey]); return { config: pageConfig, pageConfig, - loading, - error, + loading: pageConfigQuery.isLoading, + error: pageConfigQuery.error instanceof Error ? pageConfigQuery.error.message : null, pageName: resolvedPageName, patchConfig, refreshConfig, diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 16255397..89ad44f1 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitorsById, putNotificationsForwarders, putPageConfig } from './sdk.gen'; -export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; +export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putPageConfig } from './sdk.gen'; +export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponse, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponse, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponse, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponse, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses } from './types.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index 7c91bbed..3cf8f8aa 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; +import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsSearchItemsData, GetJobsSearchItemsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSearchItemsData, GetSearchItemsFrequentlyUsedData, GetSearchItemsFrequentlyUsedResponses, GetSearchItemsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostSearchItemsUsageStatsData, PostSearchItemsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutPageConfigData, PutPageConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -302,6 +302,94 @@ export const postMonitoringStatus = (optio } }); +/** + * List SSH hosts + */ +export const getMonitoringSshHosts = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/monitoring/ssh-hosts', ...options }); + +/** + * Create SSH host + */ +export const postMonitoringSshHosts = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/monitoring/ssh-hosts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Delete SSH host + */ +export const deleteMonitoringSshHostsById = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/monitoring/ssh-hosts/{id}', ...options }); + +/** + * Update SSH host + */ +export const putMonitoringSshHostsById = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/monitoring/ssh-hosts/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * List system-agent hosts + */ +export const getMonitoringHosts = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/monitoring/hosts', ...options }); + +/** + * Create system-agent host + */ +export const postMonitoringHosts = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/monitoring/hosts', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Delete system-agent host + */ +export const deleteMonitoringHostsById = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/monitoring/hosts/{id}', ...options }); + +/** + * Get system-agent host + */ +export const getMonitoringHostsById = (options: Options): RequestResult => (options.client ?? client).get({ url: '/monitoring/hosts/{id}', ...options }); + +/** + * Update system-agent host + */ +export const putMonitoringHostsById = (options: Options): RequestResult => (options.client ?? client).put({ + url: '/monitoring/hosts/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Get current system-agent host stats + */ +export const getMonitoringHostsByIdStats = (options: Options): RequestResult => (options.client ?? client).get({ url: '/monitoring/hosts/{id}/stats', ...options }); + +/** + * Get system-agent host metric history + */ +export const getMonitoringHostsByIdHistory = (options: Options): RequestResult => (options.client ?? client).get({ url: '/monitoring/hosts/{id}/history', ...options }); + +/** + * Refresh a system-agent host + */ +export const postMonitoringHostsByIdRefresh = (options: Options): RequestResult => (options.client ?? client).post({ url: '/monitoring/hosts/{id}/refresh', ...options }); + /** * Get news subscriptions */ diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index cea47460..e368acd7 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -12,6 +12,8 @@ export type Error = { [key: string]: unknown; }; +export type Id = string; + export type JsonBody = GenericObject; export type GetAppConfigData = { @@ -631,22 +633,237 @@ export type PostMonitoringStatusErrors = { * Bad Request */ 400: Error; +}; + +export type PostMonitoringStatusError = PostMonitoringStatusErrors[keyof PostMonitoringStatusErrors]; + +export type PostMonitoringStatusResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PostMonitoringStatusResponse = PostMonitoringStatusResponses[keyof PostMonitoringStatusResponses]; + +export type GetMonitoringSshHostsData = { + body?: never; + path?: never; + query?: never; + url: '/monitoring/ssh-hosts'; +}; + +export type GetMonitoringSshHostsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type GetMonitoringSshHostsResponse = GetMonitoringSshHostsResponses[keyof GetMonitoringSshHostsResponses]; + +export type PostMonitoringSshHostsData = { + body?: JsonBody; + path?: never; + query?: never; + url: '/monitoring/ssh-hosts'; +}; + +export type PostMonitoringSshHostsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PostMonitoringSshHostsResponse = PostMonitoringSshHostsResponses[keyof PostMonitoringSshHostsResponses]; + +export type DeleteMonitoringSshHostsByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/monitoring/ssh-hosts/{id}'; +}; + +export type DeleteMonitoringSshHostsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type DeleteMonitoringSshHostsByIdResponse = DeleteMonitoringSshHostsByIdResponses[keyof DeleteMonitoringSshHostsByIdResponses]; + +export type PutMonitoringSshHostsByIdData = { + body?: JsonBody; + path: { + id: string; + }; + query?: never; + url: '/monitoring/ssh-hosts/{id}'; +}; + +export type PutMonitoringSshHostsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PutMonitoringSshHostsByIdResponse = PutMonitoringSshHostsByIdResponses[keyof PutMonitoringSshHostsByIdResponses]; + +export type GetMonitoringHostsData = { + body?: never; + path?: never; + query?: never; + url: '/monitoring/hosts'; +}; + +export type GetMonitoringHostsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type GetMonitoringHostsResponse = GetMonitoringHostsResponses[keyof GetMonitoringHostsResponses]; + +export type PostMonitoringHostsData = { + body?: JsonBody; + path?: never; + query?: never; + url: '/monitoring/hosts'; +}; + +export type PostMonitoringHostsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PostMonitoringHostsResponse = PostMonitoringHostsResponses[keyof PostMonitoringHostsResponses]; + +export type DeleteMonitoringHostsByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/monitoring/hosts/{id}'; +}; + +export type DeleteMonitoringHostsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type DeleteMonitoringHostsByIdResponse = DeleteMonitoringHostsByIdResponses[keyof DeleteMonitoringHostsByIdResponses]; + +export type GetMonitoringHostsByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/monitoring/hosts/{id}'; +}; + +export type GetMonitoringHostsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type GetMonitoringHostsByIdResponse = GetMonitoringHostsByIdResponses[keyof GetMonitoringHostsByIdResponses]; + +export type PutMonitoringHostsByIdData = { + body?: JsonBody; + path: { + id: string; + }; + query?: never; + url: '/monitoring/hosts/{id}'; +}; + +export type PutMonitoringHostsByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type PutMonitoringHostsByIdResponse = PutMonitoringHostsByIdResponses[keyof PutMonitoringHostsByIdResponses]; + +export type GetMonitoringHostsByIdStatsData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/monitoring/hosts/{id}/stats'; +}; + +export type GetMonitoringHostsByIdStatsResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type GetMonitoringHostsByIdStatsResponse = GetMonitoringHostsByIdStatsResponses[keyof GetMonitoringHostsByIdStatsResponses]; + +export type GetMonitoringHostsByIdHistoryData = { + body?: never; + path: { + id: string; + }; + query?: { + timestamp?: string; + }; + url: '/monitoring/hosts/{id}/history'; +}; + +export type GetMonitoringHostsByIdHistoryResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type GetMonitoringHostsByIdHistoryResponse = GetMonitoringHostsByIdHistoryResponses[keyof GetMonitoringHostsByIdHistoryResponses]; + +export type PostMonitoringHostsByIdRefreshData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/monitoring/hosts/{id}/refresh'; +}; + +export type PostMonitoringHostsByIdRefreshErrors = { /** * Unauthorized */ 401: Error; }; -export type PostMonitoringStatusError = PostMonitoringStatusErrors[keyof PostMonitoringStatusErrors]; +export type PostMonitoringHostsByIdRefreshError = PostMonitoringHostsByIdRefreshErrors[keyof PostMonitoringHostsByIdRefreshErrors]; -export type PostMonitoringStatusResponses = { +export type PostMonitoringHostsByIdRefreshResponses = { /** * OK */ 200: GenericObject; }; -export type PostMonitoringStatusResponse = PostMonitoringStatusResponses[keyof PostMonitoringStatusResponses]; +export type PostMonitoringHostsByIdRefreshResponse = PostMonitoringHostsByIdRefreshResponses[keyof PostMonitoringHostsByIdRefreshResponses]; export type GetNewsData = { body?: never; diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index c589a059..37878d8e 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -19,7 +19,7 @@ import config from "@/lib/config"; import { client } from "./api/client.gen"; import * as sdk from "./api/sdk.gen"; -const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getSearchItems, getSearchItemsFrequentlyUsed, postSearchItemsUsageStats, getLocations, getJobsPullIcons, postWallpapers, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders } = sdk; +const { getAppConfig, getAppInfo, postAuthLogin, postAuthChangePassword, postAuthSignup, postAuthValidateAuth, deleteAuthDeleteAccount, patchAuthUpdateUserProperty, getLinksCollections, postLinksCollections, putLinksCollectionsByCollectionId, postLinksTags, putLinksTagsByTagId, getLinksHomeGroups, postLinksHomeGroups, putLinksFoldersByFolderIdIcon, getLinksHome, getLinksFolders, postLinksFolders, getLinksItems, getLinksTags, postLinksItems, putLinksItemsByLinkId, deleteLinksItemsByLinkId, postLinksReorder, getIntegrations, postIntegrations, putIntegrationsById, deleteIntegrationsById, postIntegrationsTestEndpoint, getIntegrationsWidgetProperties, getWidgetsByIntegration, postIntegrationsConsumerData, getIntegrationsCaldavEvents, postIntegrationsProxyAction, getWidgets, getGlanceables, getGlanceablesByIntegration, getMonitoringStatus, postMonitoringStatus, getMonitoringSshHosts, postMonitoringSshHosts, putMonitoringSshHostsById, getMonitoringHosts, postMonitoringHosts, getMonitoringHostsByIdHistory, getMonitors, getMonitorsById, putMonitorsById, postMonitors, deleteMonitorsById, getNewsFeedRecordsById, postNewsFeedRecords, getNewsSubscriptions, getNewsFeeds, getNewsFeedMetadata, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFeedRecordsById, postNewsFixMissingTitles, getPageConfig, getPageConfigUserPages, putPageConfig, postPageConfigHome, postPageConfigMigrateLegacy, postPageConfigIntegrationData, getSearchItems, getSearchItemsFrequentlyUsed, postSearchItemsUsageStats, getLocations, getJobsPullIcons, postWallpapers, getNotifications, getNotificationsTopics, postNotificationsTopics, deleteNotificationsTopics, postNotificationsMarkAsRead, postNotificationsTest, getNotificationsTopicTokens, postNotificationsTopicTokens, deleteNotificationsTopicTokens, getNotificationsForwarders, postNotificationsForwarders, putNotificationsForwarders, deleteNotificationsForwarders } = sdk; export * from "./api/sdk.gen"; export type { GenericObject, Error } from "./api/types.gen"; @@ -416,36 +416,16 @@ export async function deleteMonitorAction(auth: ActionAuth, monitorId: string): return extractData(await deleteMonitorsById({ path: { id: monitorId }, headers: authHeaders(auth) })); } -async function fetchJsonAction(auth: ActionAuth, path: string, init?: RequestInit): Promise { - const response = await fetch(backendUrl(`${apiBasePath}${path}`), { - ...init, - headers: { - "Content-Type": "application/json", - ...authHeaders(auth), - ...(init?.headers || {}), - }, - }); - handleUnauthorizedResponse(response); - const data = await response.json().catch(() => null); - if (!response.ok || data?._status >= 400) { - const error = new Error(data?.error || data?.message || "Request failed") as Error & { status?: number; body?: unknown }; - error.status = response.status; - error.body = data; - throw error; - } - return data as T; -} - export async function getMonitoringSshHostsAction(auth: ActionAuth): Promise { - return fetchJsonAction(auth, "/monitoring/ssh-hosts"); + return extractData(await getMonitoringSshHosts({ headers: authHeaders(auth) })) as Promise; } export async function getMonitoringHostsAction(auth: ActionAuth): Promise { - return fetchJsonAction(auth, "/monitoring/hosts"); + return extractData(await getMonitoringHosts({ headers: authHeaders(auth) })) as Promise; } export async function createMonitoringHostAction(auth: ActionAuth, data: MonitoringHostInput): Promise { - return fetchJsonAction(auth, "/monitoring/hosts", { method: "POST", body: JSON.stringify(data) }); + return extractData(await postMonitoringHosts({ body: data, headers: authHeaders(auth) })) as Promise; } export async function getMonitoringHostHistoryAction( @@ -453,16 +433,19 @@ export async function getMonitoringHostHistoryAction( hostId: string, timestamp?: string, ): Promise { - const query = timestamp ? `?${new URLSearchParams({ timestamp })}` : ""; - return fetchJsonAction(auth, `/monitoring/hosts/${hostId}/history${query}`); + return extractData(await getMonitoringHostsByIdHistory({ + path: { id: hostId }, + query: timestamp ? { timestamp } : undefined, + headers: authHeaders(auth), + })) as Promise; } export async function createMonitoringSshHostAction(auth: ActionAuth, data: MonitoringSshHostInput): Promise { - return fetchJsonAction(auth, "/monitoring/ssh-hosts", { method: "POST", body: JSON.stringify(data) }); + return extractData(await postMonitoringSshHosts({ body: data, headers: authHeaders(auth) })) as Promise; } export async function updateMonitoringSshHostAction(auth: ActionAuth, hostId: string, data: Partial): Promise { - return fetchJsonAction(auth, `/monitoring/ssh-hosts/${hostId}`, { method: "PUT", body: JSON.stringify(data) }); + return extractData(await putMonitoringSshHostsById({ path: { id: hostId }, body: data, headers: authHeaders(auth) })) as Promise; } // --- News actions --- diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts new file mode 100644 index 00000000..ebf6706b --- /dev/null +++ b/apps/web/src/lib/queryClient.ts @@ -0,0 +1,49 @@ +import { QueryClient } from "@tanstack/react-query"; + +function shouldRetry(failureCount: number, error: unknown) { + const status = typeof error === "object" && error !== null && "status" in error + ? (error as { status?: number }).status + : undefined; + + return !(status && status >= 400 && status < 500) && failureCount < 2; +} + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: shouldRetry, + refetchOnWindowFocus: false, + }, + mutations: { retry: false }, + }, + }); +} + +export const queryKeys = { + appConfig: ["app-config"] as const, + auth: { + validation: (token: string | null) => ["auth", token, "validation"] as const, + }, + links: { + collections: ["links", "collections"] as const, + tags: ["links", "tags"] as const, + folders: (listId: string) => ["links", "folders", listId] as const, + items: (listId: string, folderId?: string) => ["links", "items", listId, folderId ?? null] as const, + home: ["links", "home"] as const, + }, + monitoring: { + monitors: ["monitoring", "monitors"] as const, + hosts: ["monitoring", "hosts"] as const, + sshHosts: ["monitoring", "ssh-hosts"] as const, + }, + // Scope authenticated resources to the active session so a user switch never + // renders another user's cached data before its first refetch completes. + pageConfig: (token: string | null, pageName: string) => ["page-config", token, pageName] as const, + news: { + subscriptions: (token: string | null) => ["news", token, "subscriptions"] as const, + feeds: (token: string | null) => ["news", token, "feeds"] as const, + savedArticles: (token: string | null, list: string | null) => ["news", token, "saved-articles", list] as const, + }, +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6401950e..50c00087 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { appRouter } from './router'; +import { QueryProvider } from '@/components/QueryProvider'; import './app/globals.css'; if ('serviceWorker' in navigator) { @@ -12,6 +13,8 @@ if ('serviceWorker' in navigator) { ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + + + ); diff --git a/bun.lock b/bun.lock index 85fa3dc2..f3345377 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -577,6 +578,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], From 1e13cbdbe4c292a9c815a08ca7f3232300c9118d Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 20 Jul 2026 19:06:09 +0200 Subject: [PATCH 2/7] refactor(web): migrate reads to tanstack query --- .../apps/links/lists/[listId]/page.tsx | 64 +++------- .../(authenticated)/apps/links/lists/page.tsx | 34 +----- .../apps/links/tags/[tagId]/page.tsx | 92 +++++--------- .../(authenticated)/apps/links/tags/page.tsx | 33 +---- .../apps/monitoring/layout.tsx | 92 +++----------- .../(authenticated)/apps/monitoring/page.tsx | 47 ++----- .../monitoring/useMonitoringLinkLookup.ts | 53 ++------ .../web/src/components/news/NewsDashboard.tsx | 79 ++++++------ .../notifications/NotificationsPage.tsx | 113 ++++++----------- apps/web/src/components/widgets/SearchBar.tsx | 115 +----------------- apps/web/src/context/ActivityContext.tsx | 12 +- apps/web/src/lib/queryClient.ts | 20 +++ 12 files changed, 205 insertions(+), 549 deletions(-) diff --git a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx index e38a1831..35388f73 100644 --- a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx @@ -1,11 +1,14 @@ "use client"; import { useParams } from "react-router-dom"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import useAuth from "@/context/useAuth"; import { getLinksCollectionsAction, getLinksFoldersAction, getLinksItemsAction, getLinksTagsAction } from '@/lib/apiClient'; import LinksDetailView, { type LinkFolderRecord, type LinkItemRecord, type LinkTagRecord } from "@/components/links/LinksDetailView"; import CreateLinksItemDialog from "@/components/links/CreateLinksItemDialog"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; +import { useQueryClient } from "@tanstack/react-query"; type LinkCollection = { id: string; @@ -17,51 +20,18 @@ type LinkCollection = { export default function LinksListDetailPage() { const { listId = "" } = useParams(); - const { token, withAuth } = useAuth(); - const [collections, setCollections] = useState([]); - const [folders, setFolders] = useState([]); - const [items, setItems] = useState([]); - const [tags, setTags] = useState([]); + const { token } = useAuth(); + const queryClient = useQueryClient(); + const collectionsQuery = useApiQuery(queryKeys.links.collections, getLinksCollectionsAction); + const foldersQuery = useApiQuery(queryKeys.links.folders(listId), (auth) => getLinksFoldersAction(auth, listId), { enabled: Boolean(listId) }); + const itemsQuery = useApiQuery(queryKeys.links.items(listId), (auth) => getLinksItemsAction(auth, listId), { enabled: Boolean(listId) }); + const tagsQuery = useApiQuery(queryKeys.links.tags, getLinksTagsAction); + const collections = (collectionsQuery.data ?? []) as LinkCollection[]; + const folders = (foldersQuery.data ?? []) as LinkFolderRecord[]; + const items = (itemsQuery.data ?? []) as LinkItemRecord[]; + const tags = (tagsQuery.data ?? []) as LinkTagRecord[]; const [createLinkOpen, setCreateLinkOpen] = useState(false); - useEffect(() => { - if (!token || !listId) return; - - let mounted = true; - - const load = async () => { - try { - const [collectionsData, foldersData, itemsData, tagsData] = await Promise.all([ - withAuth((auth) => getLinksCollectionsAction(auth)), - withAuth((auth) => getLinksFoldersAction(auth, listId)), - withAuth((auth) => getLinksItemsAction(auth, listId)), - withAuth((auth) => getLinksTagsAction(auth)), - ]); - - if (!mounted) return; - - setCollections(Array.isArray(collectionsData) ? (collectionsData as LinkCollection[]) : []); - setFolders(Array.isArray(foldersData) ? (foldersData as LinkFolderRecord[]) : []); - setItems(Array.isArray(itemsData) ? (itemsData as LinkItemRecord[]) : []); - setTags(Array.isArray(tagsData) ? (tagsData as LinkTagRecord[]) : []); - } catch (error) { - console.error("Failed to load list details:", error); - if (mounted) { - setCollections([]); - setFolders([]); - setItems([]); - setTags([]); - } - } - }; - - load(); - - return () => { - mounted = false; - }; - }, [listId, token, withAuth]); - const list = useMemo( () => collections.find((collection) => collection.id === listId) ?? null, [collections, listId], @@ -97,7 +67,7 @@ export default function LinksListDetailPage() { tags={tags} onAddLink={() => setCreateLinkOpen(true)} onFolderCreated={(folder) => { - setFolders((current) => [folder, ...current.filter((existing) => existing.id !== folder.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.links.folders(listId)], (current = []) => [folder, ...current.filter((existing) => existing.id !== folder.id)]); }} /> @@ -108,9 +78,9 @@ export default function LinksListDetailPage() { onCreated={(link) => { if (link.collection !== listId) return; - setItems((current) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.links.items(listId)], (current = []) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]); }} /> ); -} \ No newline at end of file +} diff --git a/apps/web/src/app/(authenticated)/apps/links/lists/page.tsx b/apps/web/src/app/(authenticated)/apps/links/lists/page.tsx index 29c4921a..f32dcdc2 100644 --- a/apps/web/src/app/(authenticated)/apps/links/lists/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/lists/page.tsx @@ -1,9 +1,10 @@ "use client"; import { Link } from "react-router-dom"; -import { useEffect, useMemo, useState } from "react"; -import useAuth from "@/context/useAuth"; +import { useMemo } from "react"; import { getLinksCollectionsAction } from '@/lib/apiClient'; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; type LinkCollection = { id: string; @@ -14,31 +15,8 @@ type LinkCollection = { }; export default function LinksListsPage() { - const { token, withAuth } = useAuth(); - const [collections, setCollections] = useState([]); - - useEffect(() => { - if (!token) return; - - let mounted = true; - - const load = async () => { - try { - const data = await withAuth((auth) => getLinksCollectionsAction(auth)); - if (!mounted) return; - setCollections(Array.isArray(data) ? (data as LinkCollection[]) : []); - } catch (error) { - console.error("Failed to load link lists:", error); - if (mounted) setCollections([]); - } - }; - - load(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); + const collectionsQuery = useApiQuery(queryKeys.links.collections, getLinksCollectionsAction); + const collections = (collectionsQuery.data ?? []) as LinkCollection[]; const lists = useMemo( () => collections.filter((collection) => { @@ -84,4 +62,4 @@ export default function LinksListsPage() { )} ); -} \ No newline at end of file +} diff --git a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx index 7dfa1e84..151efb5c 100644 --- a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx @@ -1,11 +1,14 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { getLinksCollectionsAction, getLinksFoldersAction, getLinksItemsAction, getLinksTagsAction } from '@/lib/apiClient'; import LinksDetailView, { type LinkFolderRecord, type LinkItemRecord, type LinkTagRecord } from "@/components/links/LinksDetailView"; import CreateLinksItemDialog from "@/components/links/CreateLinksItemDialog"; import useAuth from "@/context/useAuth"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; +import { useQueryClient } from "@tanstack/react-query"; type LinkCollection = { id: string; @@ -44,63 +47,34 @@ function includeFolderAncestors(folderId: string | undefined, foldersById: Map([]); - const [folders, setFolders] = useState([]); - const [items, setItems] = useState([]); - const [tags, setTags] = useState([]); - const [createLinkOpen, setCreateLinkOpen] = useState(false); - - useEffect(() => { - if (!token || !tagId) return; - - let mounted = true; - - const load = async () => { - try { - const [collectionsData, tagsData] = await Promise.all([ - withAuth((auth) => getLinksCollectionsAction(auth)), - withAuth((auth) => getLinksTagsAction(auth)), - ]); - - if (!mounted) return; - - const collectionRecords = Array.isArray(collectionsData) ? (collectionsData as LinkCollection[]) : []; - const collectionPayloads = await Promise.all(collectionRecords.map(async (collection) => { - const [foldersData, itemsData] = await Promise.all([ - withAuth((auth) => getLinksFoldersAction(auth, collection.id)), - withAuth((auth) => getLinksItemsAction(auth, collection.id)), - ]); - - return { - folders: Array.isArray(foldersData) ? (foldersData as LinkFolderRecord[]) : [], - items: Array.isArray(itemsData) ? (itemsData as LinkItemRecord[]) : [], - }; - })); - - if (!mounted) return; - - setCollections(Array.isArray(collectionRecords) ? collectionRecords : []); - setTags(Array.isArray(tagsData) ? (tagsData as LinkTagRecord[]) : []); - setFolders(collectionPayloads.flatMap((entry) => entry.folders)); - setItems(collectionPayloads.flatMap((entry) => entry.items)); - } catch (error) { - console.error("Failed to load tag details:", error); - if (mounted) { - setCollections([]); - setFolders([]); - setItems([]); - setTags([]); - } - } - }; - - load(); - - return () => { - mounted = false; + const { token } = useAuth(); + const queryClient = useQueryClient(); + const detailQuery = useApiQuery(queryKeys.links.tagDetail(tagId), async (auth) => { + const collectionsData = await getLinksCollectionsAction(auth); + const tagsData = await getLinksTagsAction(auth); + const collectionRecords = Array.isArray(collectionsData) ? collectionsData as LinkCollection[] : []; + const collectionPayloads = await Promise.all(collectionRecords.map(async (collection) => { + const [foldersData, itemsData] = await Promise.all([ + getLinksFoldersAction(auth, collection.id), + getLinksItemsAction(auth, collection.id), + ]); + return { + folders: Array.isArray(foldersData) ? foldersData as LinkFolderRecord[] : [], + items: Array.isArray(itemsData) ? itemsData as LinkItemRecord[] : [], + }; + })); + return { + collections: collectionRecords, + tags: Array.isArray(tagsData) ? tagsData as LinkTagRecord[] : [], + folders: collectionPayloads.flatMap((entry) => entry.folders), + items: collectionPayloads.flatMap((entry) => entry.items), }; - }, [tagId, token, withAuth]); + }, { enabled: Boolean(tagId) }); + const collections = detailQuery.data?.collections ?? []; + const folders = detailQuery.data?.folders ?? []; + const items = detailQuery.data?.items ?? []; + const tags = detailQuery.data?.tags ?? []; + const [createLinkOpen, setCreateLinkOpen] = useState(false); const tag = useMemo( () => tags.find((entry) => entry.id === tagId) ?? null, @@ -171,9 +145,9 @@ export default function LinksTagDetailPage() { const createdTagIds = normalizeTagIds(link.tags); if (!createdTagIds.includes(tagId)) return; - setItems((current) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, items: [link as LinkItemRecord, ...current.items.filter((item) => item.id !== link.id)] } : current); }} /> ); -} \ No newline at end of file +} diff --git a/apps/web/src/app/(authenticated)/apps/links/tags/page.tsx b/apps/web/src/app/(authenticated)/apps/links/tags/page.tsx index f1e301f8..6a1eb78c 100644 --- a/apps/web/src/app/(authenticated)/apps/links/tags/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/tags/page.tsx @@ -1,9 +1,9 @@ "use client"; import { Link } from "react-router-dom"; -import { useEffect, useState } from "react"; -import useAuth from "@/context/useAuth"; import { getLinksTagsAction } from '@/lib/apiClient'; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; type LinkTag = { id: string; @@ -12,31 +12,8 @@ type LinkTag = { }; export default function LinksTagsPage() { - const { token, withAuth } = useAuth(); - const [tags, setTags] = useState([]); - - useEffect(() => { - if (!token) return; - - let mounted = true; - - const load = async () => { - try { - const data = await withAuth((auth) => getLinksTagsAction(auth)); - if (!mounted) return; - setTags(Array.isArray(data) ? (data as LinkTag[]) : []); - } catch (error) { - console.error("Failed to load tags:", error); - if (mounted) setTags([]); - } - }; - - load(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); + const tagsQuery = useApiQuery(queryKeys.links.tags, getLinksTagsAction); + const tags = (tagsQuery.data ?? []) as LinkTag[]; return (
@@ -75,4 +52,4 @@ export default function LinksTagsPage() { )}
); -} \ No newline at end of file +} diff --git a/apps/web/src/app/(authenticated)/apps/monitoring/layout.tsx b/apps/web/src/app/(authenticated)/apps/monitoring/layout.tsx index 688699e7..d3e1acd0 100644 --- a/apps/web/src/app/(authenticated)/apps/monitoring/layout.tsx +++ b/apps/web/src/app/(authenticated)/apps/monitoring/layout.tsx @@ -1,9 +1,8 @@ "use client"; import { Outlet, useSearchParams } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import AppTemplate, { BottomTab, Content, GroupLabel, Sidebar, Tab } from "@/components/apps/LayoutTemplate"; -import useAuth from "@/context/useAuth"; import { getMonitorsAction } from '@/lib/apiClient'; import { getMonitoringHostsAction, getMonitoringSshHostsAction } from '@/lib/apiClient'; import type { MonitorRecord, MonitoringHostRecord, MonitoringSshHostRecord } from '@/lib/apiClient'; @@ -14,30 +13,27 @@ import SshHostDialog from "@/components/monitoring/SshHostDialog"; import SystemAgentHostDialog from "@/components/monitoring/SystemAgentHostDialog"; import { useMonitoringLinkLookup } from "@/components/monitoring/useMonitoringLinkLookup"; import SshSessionsProvider from "@/components/monitoring/ssh/SshSessionsProvider"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; +import { useQueryClient } from "@tanstack/react-query"; +import useAuth from "@/context/useAuth"; export default function MonitoringRootLayout() { - const { token, withAuth } = useAuth(); + const queryClient = useQueryClient(); + const { token } = useAuth(); const { unreadCount } = useActivity(); - const [monitors, setMonitors] = useState([]); - const [sshHosts, setSshHosts] = useState([]); - const [hosts, setHosts] = useState([]); const [sshHostDialogOpen, setSshHostDialogOpen] = useState(false); const [systemAgentDialogOpen, setSystemAgentDialogOpen] = useState(false); const [editingSshHost, setEditingSshHost] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); const { entryById } = useMonitoringLinkLookup(); - useEffect(() => { - if (!token) { - setHosts([]); - return; - } - let mounted = true; - void withAuth((auth) => getMonitoringHostsAction(auth)) - .then((hostList) => { if (mounted) setHosts(Array.isArray(hostList) ? hostList : []); }) - .catch((err) => { console.error("Failed to load monitoring hosts:", err); if (mounted) setHosts([]); }); - return () => { mounted = false; }; - }, [token, withAuth]); + const monitorsQuery = useApiQuery(queryKeys.monitoring.monitors, getMonitorsAction); + const hostsQuery = useApiQuery(queryKeys.monitoring.hosts, getMonitoringHostsAction); + const sshHostsQuery = useApiQuery(queryKeys.monitoring.sshHosts, getMonitoringSshHostsAction); + const monitors = (monitorsQuery.data ?? []) as MonitorRecord[]; + const hosts = (hostsQuery.data ?? []) as MonitoringHostRecord[]; + const sshHosts = (sshHostsQuery.data ?? []) as MonitoringSshHostRecord[]; const monitorDialogOpen = searchParams.get("newMonitor") === "true"; @@ -58,62 +54,6 @@ export default function MonitoringRootLayout() { setSshHostDialogOpen(true); }; - useEffect(() => { - if (!token) { - setMonitors([]); - return; - } - - let mounted = true; - - const loadMonitors = async () => { - try { - const monitorList = await withAuth((auth) => getMonitorsAction(auth)); - if (!mounted) return; - setMonitors(Array.isArray(monitorList) ? monitorList : []); - } catch (err) { - console.error("Failed to load monitors:", err); - if (mounted) { - setMonitors([]); - } - } - }; - - loadMonitors(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); - - useEffect(() => { - if (!token) { - setSshHosts([]); - return; - } - - let mounted = true; - - const loadSshHosts = async () => { - try { - const sshHostList = await withAuth((auth) => getMonitoringSshHostsAction(auth)); - if (!mounted) return; - setSshHosts(Array.isArray(sshHostList) ? sshHostList : []); - } catch (err) { - console.error("Failed to load SSH hosts:", err); - if (mounted) { - setSshHosts([]); - } - } - }; - - loadSshHosts(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); - return ( @@ -200,7 +140,7 @@ export default function MonitoringRootLayout() { } }} onCreated={(monitor) => { - setMonitors((current) => [monitor, ...current.filter((existing) => existing.id !== monitor.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.monitoring.monitors], (current = []) => [monitor, ...current.filter((existing) => existing.id !== monitor.id)]); closeMonitorDialog(); }} onSystemAgentRequested={() => setSystemAgentDialogOpen(true)} @@ -209,7 +149,7 @@ export default function MonitoringRootLayout() { setHosts((current) => [host, ...current.filter((existing) => existing.id !== host.id)])} + onSaved={(host) => queryClient.setQueryData(["api", token, ...queryKeys.monitoring.hosts], (current = []) => [host, ...current.filter((existing) => existing.id !== host.id)])} /> { - setSshHosts((current) => [host, ...current.filter((existing) => existing.id !== host.id)]); + queryClient.setQueryData(["api", token, ...queryKeys.monitoring.sshHosts], (current = []) => [host, ...current.filter((existing) => existing.id !== host.id)]); }} /> diff --git a/apps/web/src/app/(authenticated)/apps/monitoring/page.tsx b/apps/web/src/app/(authenticated)/apps/monitoring/page.tsx index af70f54a..3cba6866 100644 --- a/apps/web/src/app/(authenticated)/apps/monitoring/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/monitoring/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Icon } from "@iconify-icon/react"; -import useAuth from "@/context/useAuth"; import { getMonitorsAction } from "@/lib/apiClient"; import type { MonitorPing, MonitorRecord } from "@dashwise/types/sdk"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; type ParsedOutlier = { created: string; @@ -150,10 +151,10 @@ function buildOutliers(monitors: MonitorRecord[]) { export default function MonitoringHomePage() { const [searchParams, setSearchParams] = useSearchParams(); - const { token, withAuth } = useAuth(); - const [monitors, setMonitors] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const monitorsQuery = useApiQuery(queryKeys.monitoring.monitors, getMonitorsAction); + const monitors = (monitorsQuery.data ?? []) as MonitorRecord[]; + const loading = monitorsQuery.isLoading; + const error = monitorsQuery.error ? "Unable to load monitoring overview." : null; const openMonitorDialog = () => { const next = new URLSearchParams(searchParams); @@ -161,40 +162,6 @@ export default function MonitoringHomePage() { setSearchParams(next); }; - useEffect(() => { - if (!token) { - setMonitors([]); - setLoading(false); - return; - } - - let mounted = true; - - const loadMonitors = async () => { - setLoading(true); - setError(null); - try { - const data = await withAuth((auth) => getMonitorsAction(auth)); - if (!mounted) return; - setMonitors(Array.isArray(data) ? data : []); - } catch (err) { - console.error("Failed to load monitoring overview:", err); - if (mounted) { - setError("Unable to load monitoring overview."); - setMonitors([]); - } - } finally { - if (mounted) setLoading(false); - } - }; - - void loadMonitors(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); - const summary = useMemo(() => { return monitors.reduce( (acc, monitor) => { diff --git a/apps/web/src/components/monitoring/useMonitoringLinkLookup.ts b/apps/web/src/components/monitoring/useMonitoringLinkLookup.ts index d181d90c..723525d2 100644 --- a/apps/web/src/components/monitoring/useMonitoringLinkLookup.ts +++ b/apps/web/src/components/monitoring/useMonitoringLinkLookup.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import useAuth from "@/context/useAuth"; +import { useMemo } from "react"; import { getHomeLinksAction, getLinksCollectionsAction, getLinksItemsAction } from '@/lib/apiClient'; +import { useApiQuery } from "@/hooks/useApiQuery"; export type MonitoringLinkLookupEntry = { id: string; @@ -14,26 +14,10 @@ export type MonitoringLinkLookupEntry = { }; export function useMonitoringLinkLookup() { - const { token, withAuth } = useAuth(); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!token) { - setEntries([]); - setLoading(false); - return; - } - - let mounted = true; - - const load = async () => { - setLoading(true); - - try { + const lookupQuery = useApiQuery(["monitoring", "link-lookup"], async (auth) => { const [homeLinks, collections] = await Promise.all([ - withAuth((auth) => getHomeLinksAction(auth)), - withAuth((auth) => getLinksCollectionsAction(auth)), + getHomeLinksAction(auth), + getLinksCollectionsAction(auth), ]); const collectionRecords = Array.isArray(collections) ? collections : []; @@ -46,13 +30,11 @@ export function useMonitoringLinkLookup() { const itemsByCollection = await Promise.all( collectionRecords.map(async (collection: any) => { - const items = await withAuth((auth) => getLinksItemsAction(auth, String(collection.id))); + const items = await getLinksItemsAction(auth, String(collection.id)); return { collection, items: Array.isArray(items) ? items : [] }; }), ); - if (!mounted) return; - const nextEntries: MonitoringLinkLookupEntry[] = []; for (const item of Array.isArray(homeLinks) ? homeLinks : []) { @@ -80,25 +62,10 @@ export function useMonitoringLinkLookup() { } } - setEntries(nextEntries); - } catch (err) { - console.error("Failed to load monitoring link lookup:", err); - if (mounted) { - setEntries([]); - } - } finally { - if (mounted) { - setLoading(false); - } - } - }; - - void load(); - - return () => { - mounted = false; - }; - }, [token, withAuth]); + return nextEntries; + }); + const entries = lookupQuery.data ?? []; + const loading = lookupQuery.isLoading; const entryById = useMemo(() => new Map(entries.map((entry) => [entry.id, entry])), [entries]); diff --git a/apps/web/src/components/news/NewsDashboard.tsx b/apps/web/src/components/news/NewsDashboard.tsx index 9ff8bc2b..fbb64c69 100644 --- a/apps/web/src/components/news/NewsDashboard.tsx +++ b/apps/web/src/components/news/NewsDashboard.tsx @@ -48,6 +48,8 @@ import type { NewsFeedRecord, NewsSavedArticlesResponse, } from "@dashwise/types/sdk"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; export default function NewsDashboardComponent() { const navigate = useNavigate(); @@ -85,6 +87,38 @@ export default function NewsDashboardComponent() { const itemsPerPage = 15; const { token, withAuth } = useAuth(); + const feedQuery = useApiQuery( + activeSavedList + ? queryKeys.news.savedArticles(token, activeSavedList) + : queryKeys.news.feed(token, activeFeedId, currentPage), + async (auth) => activeSavedList + ? getNewsSavedArticlesAction(auth, activeSavedList) + : getNewsFeedAction(auth, activeFeedId, itemsPerPage, (currentPage - 1) * itemsPerPage), + ); + const allSavedArticlesQuery = useApiQuery( + queryKeys.news.savedArticles(token, null), + (auth) => getNewsSavedArticlesAction(auth), + { enabled: !activeSavedList }, + ); + + useEffect(() => { + setSavedArticlesData(null); + }, [activeFeedId]); + + useEffect(() => { + if (!feedQuery.data) return; + if (activeSavedList) { + const data = feedQuery.data as NewsSavedArticlesResponse; + setSavedArticlesData(data); + setFeed(data.articles.map((article) => article.json)); + setFeedTotal(data.articles.length); + } else { + const data = feedQuery.data as { items?: NewsFeedItem[]; total?: number }; + setSavedArticlesData(allSavedArticlesQuery.data ?? null); + setFeed(Array.isArray(data.items) ? data.items : []); + setFeedTotal(Number(data.total || 0)); + } + }, [activeSavedList, allSavedArticlesQuery.data, feedQuery.data]); // --- Auth redirect --- useEffect(() => { @@ -93,45 +127,14 @@ export default function NewsDashboardComponent() { if (!token) return null; - const loadFeed = async (page = 1) => { - if (!token) return; - - try { - setFeed(null); - setFeedTotal(0); - - if (activeSavedList) { - const data = await withAuth((auth) => getNewsSavedArticlesAction(auth, activeSavedList)); - setSavedArticlesData(data); - setFeed(data.articles.map((article) => article.json)); - setFeedTotal(data.articles.length); - return; - } - - const limit = itemsPerPage; - const offset = (page - 1) * itemsPerPage; - const data = await withAuth((auth) => - getNewsFeedAction(auth, activeFeedId, limit, offset) - ); - setFeed(Array.isArray(data.items) ? data.items : []); - setFeedTotal(Number(data.total || 0)); - } catch (err) { - console.error("Failed to load news:", err); - } + const loadFeed = async () => { + await feedQuery.refetch(); }; // --- Fetch news --- useEffect(() => { setCurrentPage(1); - loadFeed(1); - }, [token, activeFeedId]); - - useEffect(() => { - if (!token) return; - withAuth((auth) => getNewsSavedArticlesAction(auth)) - .then(setSavedArticlesData) - .catch((err) => console.error("Failed to load saved articles:", err)); - }, [token, withAuth]); + }, [activeFeedId]); useEffect(() => { if (!sidebarAction) return; @@ -329,7 +332,7 @@ export default function NewsDashboardComponent() { try { await withAuth((auth) => refreshNewsFeedAction(auth, targetFeedIds)); setRefreshStatus("Fetching latest articles…"); - await loadFeed(currentPage); + await loadFeed(); } catch (err) { console.error("Refresh failed:", err); } finally { @@ -555,7 +558,7 @@ export default function NewsDashboardComponent() { const handlePageChange = (page: number) => { if (page === currentPage) return; setCurrentPage(page); - void loadFeed(page); + void loadFeed(); }; return ( @@ -635,6 +638,10 @@ export default function NewsDashboardComponent() { /> ))} + {feed && activeSavedList && paginatedArticles.length === 0 && ( +
No saved articles in this list
+ )} + {/* Pagination */} {feed && totalPages > 1 && (
diff --git a/apps/web/src/components/notifications/NotificationsPage.tsx b/apps/web/src/components/notifications/NotificationsPage.tsx index c6e94038..8c283ddf 100644 --- a/apps/web/src/components/notifications/NotificationsPage.tsx +++ b/apps/web/src/components/notifications/NotificationsPage.tsx @@ -33,6 +33,10 @@ import CreateForwarderDialogComponent from "@/components/notifications/CreateFor import CreateTopicTokenDialogComponent from "@/components/notifications/CreateTopicTokenDialog"; import useAuth from "@/context/useAuth"; import { useActivity } from "@/context/ActivityContext"; +import { useApiMutation } from "@/hooks/useApiMutation"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; export type NotificationItem = { id: string; @@ -117,9 +121,15 @@ function getNotificationDescription(notif: NotificationItem): React.ReactNode { export default function NotificationsPage() { const { token, withAuth } = useAuth(); - const { notifications: activityNotifications, refresh } = useActivity(); - const notifications = activityNotifications as NotificationItem[]; - const [topics, setTopics] = useState([]); + const queryClient = useQueryClient(); + const { refresh } = useActivity(); + const notificationsQuery = useQuery({ + queryKey: ["api", token, ...queryKeys.notifications.items(token)], + enabled: false, + }); + const notifications = (notificationsQuery.data ?? []) as NotificationItem[]; + const topicsQuery = useApiQuery(queryKeys.notifications.topics(token), getNotificationTopicsAction); + const topics = (topicsQuery.data?.items ?? []) as TopicItem[]; const [activeTopic, setActiveTopic] = useState(null); const [createTopicOpen, setCreateTopicOpen] = useState(false); const [createTopicTitle, setCreateTopicTitle] = useState(""); @@ -151,71 +161,26 @@ export default function NotificationsPage() { const tokensSectionRef = useRef(null); const forwardersSectionRef = useRef(null); - const fetchData = useCallback(async () => { - if (!token) return; - - try { - const topicResp = await withAuth((auth) => getNotificationTopicsAction(auth)); - const nextTopics = topicResp?.items || []; - setTopics(nextTopics); - setActiveTopic((current) => - current && !nextTopics.some((topic) => topic.id === current) - ? null - : current - ); - } catch (err) { - console.error("Notifications/topics fetch failed:", err); - } - }, [token, withAuth]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const fetchTopicDetails = useCallback(async () => { - if (!token || !topicDetailsTopic) return; - - setTopicDetailsLoading(true); - try { - const [tokensResp, forwardersResp] = await withAuth((auth) => - Promise.all([ - listTopicTokensAction(auth), - getForwardersAction(auth), - ]) - ); - - const tokens = (tokensResp?.items || []).filter( - (item: TopicTokenItem) => { - const topicId = - typeof item.topic === "string" - ? item.topic - : item.topic?.id; - return topicId === topicDetailsTopic.id; - } - ); - const forwarders = (forwardersResp?.items || []).filter( - (item: TopicForwarderItem) => { - const topicId = - typeof item.topic === "string" - ? item.topic - : item.topic?.id; - return topicId === topicDetailsTopic.id; - } - ); - - setTopicDetailsTokens(tokens); - setTopicDetailsForwarders(forwarders); - } catch (err) { - console.error("Failed to load topic details:", err); - } finally { - setTopicDetailsLoading(false); - } - }, [token, withAuth, topicDetailsTopic]); + const tokensQuery = useApiQuery(queryKeys.notifications.tokens(token), listTopicTokensAction, { enabled: topicDetailsOpen }); + const forwardersQuery = useApiQuery(queryKeys.notifications.forwarders(token), getForwardersAction, { enabled: topicDetailsOpen }); + const topicTokens = (tokensQuery.data?.items ?? []) as TopicTokenItem[]; + const topicForwarders = (forwardersQuery.data?.items ?? []) as TopicForwarderItem[]; useEffect(() => { if (!topicDetailsOpen || !topicDetailsTopic) return; - fetchTopicDetails(); - }, [fetchTopicDetails, topicDetailsOpen, topicDetailsTopic]); + const topicId = topicDetailsTopic.id; + setTopicDetailsTokens(topicTokens.filter((item) => (typeof item.topic === "string" ? item.topic : item.topic?.id) === topicId)); + setTopicDetailsForwarders(topicForwarders.filter((item) => (typeof item.topic === "string" ? item.topic : item.topic?.id) === topicId)); + setTopicDetailsLoading(tokensQuery.isLoading || forwardersQuery.isLoading); + }, [topicDetailsOpen, topicDetailsTopic, topicTokens, topicForwarders, tokensQuery.isLoading, forwardersQuery.isLoading]); + + const invalidateNotifications = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ["api", token, "notifications"] }); + refresh(); + }, [queryClient, refresh, token]); + const markReadMutation = useApiMutation((auth, ids: string[]) => markNotificationsAsReadAction(auth, ids), { onSuccess: invalidateNotifications }); + const createTopicMutation = useApiMutation((auth, title: string) => createNotificationTopicAction(auth, title), { onSuccess: invalidateNotifications }); + const deleteTopicMutation = useApiMutation((auth, topicId: string) => deleteNotificationTopicAction(auth, topicId), { onSuccess: invalidateNotifications }); useEffect(() => { if (!topicDetailsOpen || !topicDetailsSection) return; @@ -261,10 +226,7 @@ export default function NotificationsPage() { const markAsRead = async (notifId: string) => { if (!token) return; try { - await withAuth((auth) => - markNotificationsAsReadAction(auth, [notifId]) - ); - refresh(); + await markReadMutation.mutateAsync([notifId]); } catch (err) { console.error("Failed to mark notification as read:", err); } @@ -273,8 +235,7 @@ export default function NotificationsPage() { const markAllAsRead = async () => { if (!token) return; try { - await withAuth((auth) => markNotificationsAsReadAction(auth, [])); - refresh(); + await markReadMutation.mutateAsync([]); } catch (err) { console.error("Failed to mark all notifications as read:", err); } @@ -294,12 +255,9 @@ export default function NotificationsPage() { setCreatingTopic(true); try { - const result = await withAuth((auth) => - createNotificationTopicAction(auth, title) - ); + const result = await createTopicMutation.mutateAsync(title); setCreateTopicTitle(""); setCreateTopicOpen(false); - await fetchData(); if (result?.topicId) { setActiveTopic(result.topicId); } @@ -319,14 +277,11 @@ export default function NotificationsPage() { if (!confirmed) return; try { - await withAuth((auth) => - deleteNotificationTopicAction(auth, topic.id) - ); + await deleteTopicMutation.mutateAsync(topic.id); setTopicToDelete(null); if (activeTopic === topic.id) { setActiveTopic(null); } - await fetchData(); } catch (err) { console.error("Failed to delete topic:", err); alert("Failed to delete topic"); diff --git a/apps/web/src/components/widgets/SearchBar.tsx b/apps/web/src/components/widgets/SearchBar.tsx index 249f7028..e35c5103 100644 --- a/apps/web/src/components/widgets/SearchBar.tsx +++ b/apps/web/src/components/widgets/SearchBar.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react'; import useAuth from "@/context/useAuth"; import CommandBar from './CommandBar'; import { getSearchItemsAction } from '@/lib/apiClient'; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { queryKeys } from "@/lib/queryClient"; type SearchBarProps = { useRedirect: boolean; @@ -26,18 +28,6 @@ type SearchItem = { tags?: string[]; }; -type SearchItemsCache = { - fetchedAt: number; - items: SearchItem[]; -}; - -const SEARCH_ITEMS_CACHE_PREFIX = "dashwise_search_items_cache"; -const SEARCH_ITEMS_CACHE_TTL_MS = 10 * 60 * 1000; - -function getSearchItemsCacheKey(userId: string | null | undefined) { - return `${SEARCH_ITEMS_CACHE_PREFIX}:${userId ?? "anonymous"}`; -} - function normalizeSearchItems(raw: unknown): SearchItem[] { if (Array.isArray(raw)) { return raw.filter((item): item is SearchItem => !!item && typeof item === "object"); @@ -57,53 +47,16 @@ function normalizeSearchItems(raw: unknown): SearchItem[] { } } -function readSearchItemsCache(cacheKey: string): SearchItemsCache | null { - try { - const raw = localStorage.getItem(cacheKey); - if (!raw) return null; - - const parsed = JSON.parse(raw) as Partial; - if (!Array.isArray(parsed.items) || typeof parsed.fetchedAt !== "number") { - return null; - } - - return { - fetchedAt: parsed.fetchedAt, - items: normalizeSearchItems(parsed.items), - }; - } catch { - return null; - } -} - -function writeSearchItemsCache(cacheKey: string, items: SearchItem[]) { - try { - const payload: SearchItemsCache = { - fetchedAt: Date.now(), - items, - }; - - localStorage.setItem(cacheKey, JSON.stringify(payload)); - } catch { - // ignore storage errors - } -} - -function isCacheFresh(cache: SearchItemsCache | null) { - return !!cache && Date.now() - cache.fetchedAt < SEARCH_ITEMS_CACHE_TTL_MS; -} - export default function SearchBar({ useRedirect, defaultOpen }: SearchBarProps) { const [redirecting, setRedirecting] = useState(false); const [open, setOpen] = useState(() => !!defaultOpen); // control CommandBar const didMountRef = useRef(false); - const { user, withAuth } = useAuth(); + const { user } = useAuth(); // fetched items from /api/v1/searchItems - const [searchItems, setSearchItems] = useState([]); - const [loadingItems, setLoadingItems] = useState(false); - const [itemsError, setItemsError] = useState(null); + const searchItemsQuery = useApiQuery(queryKeys.links.search, getSearchItemsAction, { enabled: open }); + const searchItems = normalizeSearchItems(searchItemsQuery.data); useEffect(() => { if (!didMountRef.current) { @@ -123,64 +76,6 @@ export default function SearchBar({ useRedirect, defaultOpen }: SearchBarProps) setOpen(true); }; - // Fetch items when the command bar is opened. - // Uses Authorization: Bearer from localStorage - const cacheKey = getSearchItemsCacheKey(user?.id); - - useEffect(() => { - if (!open) return; - - let cancelled = false; - - const cached = readSearchItemsCache(cacheKey); - setSearchItems(cached?.items ?? []); - - if (isCacheFresh(cached)) { - setLoadingItems(false); - setItemsError(null); - return; - } - - async function fetchItems() { - setLoadingItems(true); - setItemsError(null); - - try { - const data = await withAuth((auth) => getSearchItemsAction(auth)); - const normalized = normalizeSearchItems(data); - - if (cancelled) return; - - setSearchItems(normalized); - writeSearchItemsCache(cacheKey, normalized); - } catch (err: unknown) { - if (cancelled) return; - - const e = err as any; - console.warn('Failed to load searchItems', err); - if (e?.status === 401 || e?.status === 403) { - setItemsError('Unauthorized (invalid token)'); - } else { - setItemsError(e?.message ?? 'Failed to load items'); - } - - if (!cached?.items.length) { - setSearchItems([]); - } - } finally { - if (cancelled) return; - - setLoadingItems(false); - } - } - - fetchItems(); - - return () => { - cancelled = true; - }; - }, [open, cacheKey, withAuth]); - return ( <>
(null); const reconnectTimer = useRef(null); const [notifications, setNotifications] = useState([]); @@ -49,6 +52,7 @@ export function ActivityProvider({ children }: { children: ReactNode }) { if (!token) { setNotifications([]); setCalendarEvents([]); + queryClient.removeQueries({ queryKey: ["api", token, ...queryKeys.notifications.items(null)] }); return; } @@ -61,8 +65,10 @@ export function ActivityProvider({ children }: { children: ReactNode }) { try { const message = JSON.parse(String(event.data)); if (message.type !== "activity:snapshot") return; - setNotifications(Array.isArray(message.notifications) ? message.notifications : []); - setCalendarEvents(Array.isArray(message.calendarEvents) ? message.calendarEvents : []); + const nextNotifications = Array.isArray(message.notifications) ? message.notifications : []; + setNotifications(nextNotifications); + queryClient.setQueryData(["api", token, ...queryKeys.notifications.items(token)], nextNotifications); + setCalendarEvents(Array.isArray(message.calendarEvents) ? message.calendarEvents : []); } catch { // Ignore malformed activity messages and retain last known state. } @@ -79,7 +85,7 @@ export function ActivityProvider({ children }: { children: ReactNode }) { socket.current?.close(); socket.current = null; }; - }, [token]); + }, [queryClient, token]); const refresh = () => { if (socket.current?.readyState === WebSocket.OPEN) { diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts index ebf6706b..be3c9546 100644 --- a/apps/web/src/lib/queryClient.ts +++ b/apps/web/src/lib/queryClient.ts @@ -32,11 +32,17 @@ export const queryKeys = { folders: (listId: string) => ["links", "folders", listId] as const, items: (listId: string, folderId?: string) => ["links", "items", listId, folderId ?? null] as const, home: ["links", "home"] as const, + search: ["links", "search"] as const, + tagDetail: (tagId: string) => ["links", "tag-detail", tagId] as const, }, monitoring: { monitors: ["monitoring", "monitors"] as const, hosts: ["monitoring", "hosts"] as const, sshHosts: ["monitoring", "ssh-hosts"] as const, + monitor: (monitorId: string) => ["monitoring", "monitor", monitorId] as const, + host: (hostId: string) => ["monitoring", "host", hostId] as const, + history: (monitorId: string, range?: string) => ["monitoring", "history", monitorId, range ?? null] as const, + status: (monitorId: string) => ["monitoring", "status", monitorId] as const, }, // Scope authenticated resources to the active session so a user switch never // renders another user's cached data before its first refetch completes. @@ -45,5 +51,19 @@ export const queryKeys = { subscriptions: (token: string | null) => ["news", token, "subscriptions"] as const, feeds: (token: string | null) => ["news", token, "feeds"] as const, savedArticles: (token: string | null, list: string | null) => ["news", token, "saved-articles", list] as const, + feed: (token: string | null, feedId: string, page: number) => ["news", token, "feed", feedId, page] as const, + metadata: (token: string | null, feedId: string) => ["news", token, "metadata", feedId] as const, + }, + notifications: { + items: (token: string | null, unread = false) => ["notifications", token, "items", unread] as const, + topics: (token: string | null) => ["notifications", token, "topics"] as const, + tokens: (token: string | null) => ["notifications", token, "tokens"] as const, + forwarders: (token: string | null) => ["notifications", token, "forwarders"] as const, + }, + settings: { + widgets: (token: string | null) => ["settings", token, "widgets"] as const, + glanceables: (token: string | null) => ["settings", token, "glanceables"] as const, + locations: (token: string | null) => ["settings", token, "locations"] as const, + integrations: (token: string | null) => ["settings", token, "integrations"] as const, }, }; From 9f9ae26599e848705c5f011d34fb3858b57be0f0 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 20 Jul 2026 19:57:49 +0200 Subject: [PATCH 3/7] news: add rename lists --- apps/backend/src/lib/data/news.ts | 33 +++++++++++++++++++++ apps/backend/src/routes/news.route.ts | 7 ++++- apps/web/src/components/news/NewsLayout.tsx | 21 ++++++++++++- apps/web/src/lib/apiClient.ts | 10 ++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/data/news.ts b/apps/backend/src/lib/data/news.ts index 86054482..c283b49c 100644 --- a/apps/backend/src/lib/data/news.ts +++ b/apps/backend/src/lib/data/news.ts @@ -1032,6 +1032,39 @@ export async function deleteNewsSavedArticleList(userId: string, list: string): return { success: true, deletedArticles: matches.length }; } +export async function renameNewsSavedArticleList(userId: string, list: string, name: string): Promise { + const requestedList = String(list || "").trim(); + const nextName = String(name || "").trim(); + if (!requestedList) throw new Error("Saved list is required"); + if (!nextName) throw new Error("Saved list name is required"); + + const defaultList = await ensureNewsDefaultList(userId); + const lists = await getNewsSavedArticleLists(userId, defaultList); + const targetList = lists.find((entry) => entry.id === requestedList || entry.name === requestedList); + if (!targetList) throw new Error("Saved list not found"); + + const pb = await getSuperuserPB(); + const existing = await pb.collection("newsSavedArticleLists").getFullList(200, { + filter: `userId=\"${escapeFilter(userId)}\" && name=\"${escapeFilter(nextName)}\"`, + }) as Array>; + if (existing.some((record) => String(record.id || "") !== targetList.id)) { + throw new Error("A saved list with that name already exists"); + } + + const renamed = await pb.collection("newsSavedArticleLists").update(targetList.id, { name: nextName }); + if (defaultList === targetList.name || (defaultList === "readLater" && targetList.name === "Read Later")) { + const user = await pb.collection("users").getOne(userId).catch(() => null) as Record | null; + const current = user?.newsPreferences && typeof user.newsPreferences === "object" + ? user.newsPreferences as Record + : {}; + await pb.collection("users").update(userId, { + newsPreferences: { ...current, defaultList: nextName }, + }); + } + + return normalizeSavedArticleList(renamed as Record); +} + function normalizeRefreshFeedIds(feedIds?: string[] | string | null) { if (Array.isArray(feedIds)) { return Array.from(new Set(feedIds.map((feedId) => String(feedId).trim()).filter(Boolean))); diff --git a/apps/backend/src/routes/news.route.ts b/apps/backend/src/routes/news.route.ts index e633f20e..87f16232 100644 --- a/apps/backend/src/routes/news.route.ts +++ b/apps/backend/src/routes/news.route.ts @@ -2,7 +2,7 @@ import Parser from "rss-parser"; import { Hono } from "hono"; import type { Context } from "hono"; -import { createNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; +import { createNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, renameNewsSavedArticleList, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; import type { NewsFeedItem, NewsFeedMetadata, NewsFeedRecordCreateInput, NewsFeedRecordUpdateInput, NewsSubscribeInput, NewsUpdateInput } from "@dashwise/types/sdk"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; @@ -206,6 +206,11 @@ newsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteNewsSavedArticleList(userId, String(c.req.param("id") ?? "")); })) + .patch("/api/v1/news/saved-article-lists/:id", withJson(async (c) => { + const body = await readJsonBody<{ name?: string }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return renameNewsSavedArticleList(userId, String(c.req.param("id") ?? ""), String(body?.name ?? "")); + })) .get("/api/v1/news/feeds/:id", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); const limit = Number(c.req.query("limit") ?? ""); diff --git a/apps/web/src/components/news/NewsLayout.tsx b/apps/web/src/components/news/NewsLayout.tsx index 973bf097..7669eb24 100644 --- a/apps/web/src/components/news/NewsLayout.tsx +++ b/apps/web/src/components/news/NewsLayout.tsx @@ -8,6 +8,7 @@ import { getNewsFeedsAction, getNewsSavedArticlesAction, getNewsSubscriptionsAction, + renameNewsSavedArticleListAction, } from '@/lib/apiClient'; import AppTemplate, { Content, GroupLabel, Sidebar, Tab } from "@/components/apps/LayoutTemplate"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -112,6 +113,10 @@ export default function NewsLayout({ children }: { children: ReactNode }) { mutationFn: (listId: string) => deleteNewsSavedArticleListAction({ token }, listId), onSuccess: reloadSidebar, }); + const renameSavedListMutation = useMutation({ + mutationFn: ({ listId, name }: { listId: string; name: string }) => renameNewsSavedArticleListAction({ token }, listId, name), + onSuccess: reloadSidebar, + }); const userFeeds = useMemo(() => feeds.filter((entry) => entry.id && entry.id !== "all"), [feeds]); const savedSubscriptionRefs = useMemo(() => { @@ -186,6 +191,15 @@ export default function NewsLayout({ children }: { children: ReactNode }) { } }; + const renameSavedList = async (list: SavedListRecord) => { + if (!token) return; + + const name = window.prompt("Rename saved list", list.name)?.trim(); + if (!name || name === list.name) return; + + await renameSavedListMutation.mutateAsync({ listId: list.id, name }); + }; + return ( @@ -250,7 +264,12 @@ export default function NewsLayout({ children }: { children: ReactNode }) { group="Saved" dropdownActions={[ { - label: "Delete list", + label: "Rename", + icon: "fa6-solid:pen-to-square", + action: () => renameSavedList(list), + }, + { + label: "Delete", icon: "fa6-solid:trash", action: () => deleteSavedList(list), }, diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index 37878d8e..dd74cd21 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -1,4 +1,4 @@ -import type { ActionAuth, AuthUserRecord, UserPropertyValue } from "@dashwise/types/sdk"; +import type { ActionAuth, AuthUserRecord, MonitorRecord, UserPropertyValue } from "@dashwise/types/sdk"; export type { MonitorRecord } from "@dashwise/types/sdk"; import type { NewsFeedDraft, @@ -518,6 +518,14 @@ export async function deleteNewsSavedArticleListAction(auth: ActionAuth, listId: })); } +export async function renameNewsSavedArticleListAction(auth: ActionAuth, listId: string, name: string) { + return extractData(await client.patch({ + url: `/news/saved-article-lists/${encodeURIComponent(listId)}`, + body: { name }, + headers: authHeaders(auth), + })); +} + export async function getNewsFeedMetadataAction(auth: ActionAuth, url: string): Promise { return extractData(await getNewsFeedMetadata({ query: { url }, headers: authHeaders(auth) })) as Promise; } From 06391a9b68c6404721d74d2dc98204fa76377174 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 20 Jul 2026 19:59:30 +0200 Subject: [PATCH 4/7] update openapi --- apps/web/public/openapi.json | 216 +++++++++++++++++++++++ packages/api-types/src/openapi.ts | 278 +++++++++++++++++++++++++++++- 2 files changed, 493 insertions(+), 1 deletion(-) diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index e77aa1ff..a171ef8e 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -666,6 +666,212 @@ }, "400": { "$ref": "#/components/responses/JsonBadRequest" + } + } + } + }, + "/monitoring/ssh-hosts": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "List SSH hosts", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, + "post": { + "tags": [ + "monitoring" + ], + "summary": "Create SSH host", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/ssh-hosts/{id}": { + "put": { + "tags": [ + "monitoring" + ], + "summary": "Update SSH host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, + "delete": { + "tags": [ + "monitoring" + ], + "summary": "Delete SSH host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/hosts": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "List system-agent hosts", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, + "post": { + "tags": [ + "monitoring" + ], + "summary": "Create system-agent host", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/hosts/{id}": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "Get system-agent host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, + "put": { + "tags": [ + "monitoring" + ], + "summary": "Update system-agent host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, + "delete": { + "tags": [ + "monitoring" + ], + "summary": "Delete system-agent host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/hosts/{id}/stats": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "Get current system-agent host stats", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/hosts/{id}/history": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "Get system-agent host metric history", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + }, + { + "name": "timestamp", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, + "/monitoring/hosts/{id}/refresh": { + "post": { + "tags": [ + "monitoring" + ], + "summary": "Refresh a system-agent host", + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" }, "401": { "$ref": "#/components/responses/JsonUnauthorized" @@ -1921,6 +2127,16 @@ } }, "components": { + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, "schemas": { "GenericObject": { "type": "object", diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index 7e8312c8..7a8236c3 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -840,6 +840,280 @@ export interface paths { responses: { 200: components["responses"]["JsonOk"]; 400: components["responses"]["JsonBadRequest"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/ssh-hosts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List SSH hosts */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + put?: never; + /** Create SSH host */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/ssh-hosts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update SSH host */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + post?: never; + /** Delete SSH host */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/hosts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List system-agent hosts */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + put?: never; + /** Create system-agent host */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/hosts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get system-agent host */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + /** Update system-agent host */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: components["requestBodies"]["JsonBody"]; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + post?: never; + /** Delete system-agent host */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/hosts/{id}/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current system-agent host stats */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/hosts/{id}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get system-agent host metric history */ + get: { + parameters: { + query?: { + timestamp?: string; + }; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/monitoring/hosts/{id}/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh a system-agent host */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; 401: components["responses"]["JsonUnauthorized"]; }; }; @@ -2509,7 +2783,9 @@ export interface components { }; }; }; - parameters: never; + parameters: { + Id: string; + }; requestBodies: { JsonBody: { content: { From 68b05150c74e2262fcff07d51158777d58eb3e13 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Mon, 20 Jul 2026 23:01:00 +0200 Subject: [PATCH 5/7] feat(news): add Valkey feed item cache --- Dockerfile | 4 ++ apps/backend/src/jobs/news/feed-builder.ts | 47 ++++++++-------------- apps/backend/src/lib/cache/feed-items.ts | 16 ++++++++ apps/backend/src/lib/data/superuser.ts | 19 --------- docker-entrypoint.sh | 5 +++ 5 files changed, 41 insertions(+), 50 deletions(-) create mode 100644 apps/backend/src/lib/cache/feed-items.ts create mode 100755 docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 4ba16583..7bfe10f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,10 @@ RUN bun run --cwd apps/backend build FROM oven/bun:1-alpine WORKDIR /app +RUN apk add --no-cache valkey + COPY --from=pocketbase /usr/local/bin/pocketbase /usr/local/bin/pocketbase +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ENV PB_BINARY_PATH=/usr/local/bin/pocketbase ENV NODE_PATH=/app/apps/backend:/app/packages @@ -73,4 +76,5 @@ COPY --from=build /app/packages /app/packages RUN bun install --production --frozen-lockfile EXPOSE 3000 8090 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["bun", "run", "--cwd", "apps/backend", "start"] diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index c264cfff..a174999b 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -1,14 +1,12 @@ import { - createNewsFeedItemsCache, getAllNewsFeeds, getNewsFeedById, getNewsSubscriptionById, - getNewsFeedItemsCacheByUrl, updateNewsFeedRecord, - updateNewsFeedItemsCache, updateNewsSubscription, } from "../../lib/data/superuser"; import { applyNewsTopics, normalizeMaxFeedItems } from "../../lib/data/news"; +import { writeFeedItemsCache } from "../../lib/cache/feed-items"; import { createLogger } from "../../lib/logger"; import { getFeedItems } from "./helper"; @@ -156,33 +154,6 @@ export async function newsFeedBuilder(feedId?: string): Promise<{ subscription_id: subscriptionId, subscription_name: subscriptionName, }))); - try { - const existing = await getNewsFeedItemsCacheByUrl(res.feedUrl!); - - const existingItem = existing.items.length > 0 ? existing.items[0] : undefined; - - if (existingItem) { - await updateNewsFeedItemsCache(existingItem.id, { - url: res.feedUrl, - json: JSON.stringify(items), - }); - } else { - await createNewsFeedItemsCache({ - url: res.feedUrl, - json: JSON.stringify(items), - }); - } - - cachedCount++; - } catch (err: any) { - feedResult.errors++; - feedResult.details.push({ - feedId: newsFeed.id, - action: 'cache_upsert_error', - feedUrl: res.feedUrl, - error: err?.message || String(err), - }); - } } else if (res.action === 'feed_fetch_error') { feedFetchErrors++; feedResult.errors++; @@ -192,12 +163,14 @@ export async function newsFeedBuilder(feedId?: string): Promise<{ } } + let cachedItems: Array = feedItems; if (newsFeed.userId) { try { const sortedItems = feedItems.sort((left, right) => new Date(right.pubDate).getTime() - new Date(left.pubDate).getTime()); const groupedItems = await applyNewsTopics(String(newsFeed.userId), sortedItems, topicSubscriptions); + cachedItems = groupedItems.slice(0, maxFeedItems) as typeof feedItems; await updateNewsFeedRecord(newsFeed.id, { - feedCache: groupedItems.slice(0, maxFeedItems), + feedCache: cachedItems, maxFeedItems, }); feedResult.updated++; @@ -211,6 +184,18 @@ export async function newsFeedBuilder(feedId?: string): Promise<{ } } + try { + await writeFeedItemsCache(newsFeed.id, cachedItems, [newsFeed.id]); + cachedCount++; + } catch (err: any) { + feedResult.errors++; + feedResult.details.push({ + feedId: newsFeed.id, + action: 'redis_cache_write_error', + error: err?.message || String(err), + }); + } + feedResult.updated += cachedCount; feedResult.details.push({ feedId: newsFeed.id, diff --git a/apps/backend/src/lib/cache/feed-items.ts b/apps/backend/src/lib/cache/feed-items.ts new file mode 100644 index 00000000..c8a78f3e --- /dev/null +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -0,0 +1,16 @@ +import { RedisClient } from "bun"; + +const redisUrl = Bun.env.REDIS_URL || Bun.env.VALKEY_URL || "redis://127.0.0.1:6379"; +const client = new RedisClient(redisUrl); + +export async function writeFeedItemsCache( + feedId: string, + items: unknown[], + feedIds: string[] = [feedId], +) { + await client.hmset(`feedItems:${feedId}`, [ + "json", JSON.stringify(items), + "date", new Date().toISOString(), + "feedIds", JSON.stringify(feedIds), + ]); +} diff --git a/apps/backend/src/lib/data/superuser.ts b/apps/backend/src/lib/data/superuser.ts index 8f06506d..bd75f912 100644 --- a/apps/backend/src/lib/data/superuser.ts +++ b/apps/backend/src/lib/data/superuser.ts @@ -166,29 +166,10 @@ export async function createNewsSubscription(payload: Record) { return safeNull((pb) => pb.collection("newsSubscriptions").create(payload)); } -export async function getNewsFeedItemsCacheByUrl(url: string) { - return safeNull((pb) => pb.collection("newsSubscriptions").getList(1, 1, { - filter: `url="${url.replace(/"/g, '\\"')}"`, - })); -} - export async function deleteNewsSubscription(subscriptionId: string) { return safeNull((pb) => pb.collection("newsSubscriptions").delete(subscriptionId)); } -export async function updateNewsFeedItemsCache( - recordId: string, - payload: Record, -) { - return safeNull((pb) => pb.collection("newsSubscriptions").update(recordId, payload)); -} - -export async function createNewsFeedItemsCache( - payload: Record, -) { - return safeNull((pb) => pb.collection("newsSubscriptions").create(payload)); -} - export async function getQueuedNotificationItems(batchSize = 100) { return safeNull((pb) => pb.collection("notificationItems").getFullList({ filter: `forwardStatus="queued"`, diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 00000000..fd35fca1 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +valkey-server --daemonize yes --save "" --appendonly no +exec "$@" From 357093735b62f8c0d5725a7d3b64c13d2f48dbf4 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 21 Jul 2026 22:32:23 +0200 Subject: [PATCH 6/7] add .zed to .gitignroe --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c43afd3d..54e12c40 100755 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ # editor settings .vscode/ +.zed/ # testing /coverage From 36ed909b7b202aebd7eda278e83a88ed995deb5d Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Tue, 21 Jul 2026 22:33:13 +0200 Subject: [PATCH 7/7] news: use feed items cache, home link view: truncate link tile title --- apps/backend/src/jobs/news/feed-builder.ts | 2 +- apps/backend/src/lib/cache/feed-items.ts | 12 ++++ apps/backend/src/lib/data/news.ts | 44 ++++++------- .../links/CreateLinksCollectionDialog.tsx | 61 ++++++++++--------- apps/web/src/components/links/LinksLayout.tsx | 28 ++++++++- .../AddMonitoringResourceDialog.tsx | 11 +--- apps/web/src/components/widgets/LinkView.tsx | 6 +- packages/types/pocketbase/pocketbase-types.ts | 2 - packages/types/sdk-types.ts | 1 + .../1784665384_updated_newsFeeds.js | 25 ++++++++ .../1784665398_updated_newsSubscriptions.js | 25 ++++++++ 11 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 pocketbase/migrations/1784665384_updated_newsFeeds.js create mode 100644 pocketbase/migrations/1784665398_updated_newsSubscriptions.js diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index a174999b..db9347e3 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -115,6 +115,7 @@ export async function newsFeedBuilder(feedId?: string): Promise<{ fallbackThumbnailUrl: subscriptionRecord?.fallbackThumbnailUrl, }) as FeedItem[]; if (subscriptionRecord?.id) { + await writeFeedItemsCache(subscriptionRecord.id, feedItems, [subscriptionRecord.id]); await updateNewsSubscription(subscriptionRecord.id, { fetchErrors: "" }); } return { @@ -170,7 +171,6 @@ export async function newsFeedBuilder(feedId?: string): Promise<{ const groupedItems = await applyNewsTopics(String(newsFeed.userId), sortedItems, topicSubscriptions); cachedItems = groupedItems.slice(0, maxFeedItems) as typeof feedItems; await updateNewsFeedRecord(newsFeed.id, { - feedCache: cachedItems, maxFeedItems, }); feedResult.updated++; diff --git a/apps/backend/src/lib/cache/feed-items.ts b/apps/backend/src/lib/cache/feed-items.ts index c8a78f3e..17e4b437 100644 --- a/apps/backend/src/lib/cache/feed-items.ts +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -3,6 +3,18 @@ import { RedisClient } from "bun"; const redisUrl = Bun.env.REDIS_URL || Bun.env.VALKEY_URL || "redis://127.0.0.1:6379"; const client = new RedisClient(redisUrl); +export async function readFeedItemsCache(feedId: string): Promise { + const raw = await client.hget(`feedItems:${feedId}`, "json"); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + export async function writeFeedItemsCache( feedId: string, items: unknown[], diff --git a/apps/backend/src/lib/data/news.ts b/apps/backend/src/lib/data/news.ts index c283b49c..a6641396 100644 --- a/apps/backend/src/lib/data/news.ts +++ b/apps/backend/src/lib/data/news.ts @@ -33,6 +33,7 @@ import { createNewsSubscription, } from "./superuser"; import { getSuperuserPB } from "../pb/pocketbase"; +import { readFeedItemsCache } from "../cache/feed-items"; type NewsTopicDraft = { key: string; @@ -529,7 +530,6 @@ function normalizeFeedRecord(entry: Record | null): NewsFeedRec ? entry.excludedSubscriptionRefs.map((value) => String(value).trim()).filter(Boolean) : [], maxFeedItems: normalizeMaxFeedItems(entry.maxFeedItems), - feedCache: parseCachedItems(entry.feedCache), }; } @@ -551,7 +551,10 @@ export async function getNewsFeedRecord(userId: string, feedId: string): Promise if (normalizedFeedId === "all") { const allFeedRecord = (await getNewsFeedByTitle(userId, "All feed").catch(() => null)) as NewsFeedRecord | null; if (allFeedRecord) { - return normalizeFeedRecord(allFeedRecord as Record); + const normalized = normalizeFeedRecord(allFeedRecord as Record); + return normalized + ? { ...normalized, feedCache: parseCachedItems(await readFeedItemsCache(String(normalized.id))) } + : null; } return { @@ -560,7 +563,6 @@ export async function getNewsFeedRecord(userId: string, feedId: string): Promise subscriptionRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, - feedCache: [], }; } @@ -570,7 +572,10 @@ export async function getNewsFeedRecord(userId: string, feedId: string): Promise const ownerId = String((feedRecord as Record).userId ?? "").trim(); if (ownerId && ownerId !== userId) return null; - return normalizeFeedRecord(feedRecord as Record); + const normalized = normalizeFeedRecord(feedRecord as Record); + return normalized + ? { ...normalized, feedCache: parseCachedItems(await readFeedItemsCache(String(normalized.id))) } + : null; } export async function updateNewsFeedRecordForUser( @@ -611,7 +616,6 @@ export async function updateNewsFeedRecordForUser( subscriptionRefs: allSubscriptionRefs, excludedSubscriptionRefs, maxFeedItems, - feedCache: [], }); } @@ -649,7 +653,6 @@ export async function createNewsFeedRecordForUser( subscriptionRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, - feedCache: [], })) as NewsFeedRecord; return normalizeFeedRecord(createdFeed as Record); @@ -807,10 +810,9 @@ export async function getNewsFeed( // Feed caches are already grouped and sorted. Do not load or parse every // subscription JSON before checking the selected feed cache. - const selectedFeedWithCache = selectedFeed?.id - ? await getNewsFeedById(String(selectedFeed.id)).catch(() => null) as Record | null - : null; - const cachedItems = parseCachedItems(selectedFeedWithCache?.feedCache); + const cachedItems = selectedFeed?.id + ? parseCachedItems(await readFeedItemsCache(String(selectedFeed.id))) + : []; if (cachedItems.length) { return { items: cachedItems.slice(offset, offset + limit), @@ -823,15 +825,21 @@ export async function getNewsFeed( const excludedIds = await getUserExcludedSubscriptionIdsFromFeeds(feeds); const allSubscriptions = (await getAllNewsSubscriptions(2000, { - fields: "id,url,icon,title,json,linkReplaceRule,fallbackThumbnailUrl,thumbnailOverwriteUrl,userId,similarityGroupingWordsBlacklist,enableTopicGrouping,fetchErrors", + fields: "id,url,icon,title,linkReplaceRule,fallbackThumbnailUrl,thumbnailOverwriteUrl,userId,similarityGroupingWordsBlacklist,enableTopicGrouping,fetchErrors", })) as Array; const subscriptions = allSubscriptions .map(normalizeSubscription) .filter((entry: NewsSubscription | null): entry is NewsSubscription => Boolean(entry && (!entry.userId || entry.userId === userId))); + const subscriptionsWithCache = await Promise.all( + subscriptions.map(async (subscription) => ({ + ...subscription, + json: await readFeedItemsCache(String(subscription.id || "")), + })), + ); const scopedSubscriptions = subscriptionIds.size - ? subscriptions.filter((subscription) => subscription.id && subscriptionIds.has(String(subscription.id)) && !excludedIds.has(String(subscription.id))) - : subscriptions.filter((subscription) => !excludedIds.has(String(subscription.id || ""))); + ? subscriptionsWithCache.filter((subscription) => subscription.id && subscriptionIds.has(String(subscription.id)) && !excludedIds.has(String(subscription.id))) + : subscriptionsWithCache.filter((subscription) => !excludedIds.has(String(subscription.id || ""))); const feed = await buildFeedFromSubscriptions(scopedSubscriptions, feedId, feeds); const items = await applyNewsTopics(userId, feed, scopedSubscriptions); @@ -890,14 +898,11 @@ export async function getNewsSubscriptionJson(userId: string, subscriptionId: st throw new Error("News subscription not found"); } - const record = await getNewsSubscriptionById(subscription.id); - if (!record) { - throw new Error("News subscription not found"); - } + const json = await readFeedItemsCache(subscription.id); return { id: subscription.id, - json: (record as Record).json ?? [], + json: json ?? [], }; } @@ -1104,7 +1109,6 @@ export async function subscribeNewsFeed( userId, url: sub.feedUrl, icon: sub.icon, - json: existing.json ?? [], linkReplaceRule: sub.linkReplaceRule, fallbackThumbnailUrl: sub.fallbackThumbnailUrl, thumbnailOverwriteUrl: sub.thumbnailOverwriteUrl, @@ -1121,7 +1125,6 @@ export async function subscribeNewsFeed( url: sub.feedUrl, title: sub.name, icon: sub.icon, - json: [], linkReplaceRule: sub.linkReplaceRule, fallbackThumbnailUrl: sub.fallbackThumbnailUrl, thumbnailOverwriteUrl: sub.thumbnailOverwriteUrl, @@ -1167,7 +1170,6 @@ export async function updateNewsFeed( url: payload.feedUrl, title: payload.title, icon: payload.icon || target.icon || "", - json: target.json ?? [], linkReplaceRule: payload.linkReplaceRule !== undefined ? payload.linkReplaceRule : target.linkReplaceRule, fallbackThumbnailUrl: payload.fallbackThumbnailUrl !== undefined ? payload.fallbackThumbnailUrl : target.fallbackThumbnailUrl, thumbnailOverwriteUrl: payload.thumbnailOverwriteUrl !== undefined ? payload.thumbnailOverwriteUrl : target.thumbnailOverwriteUrl, diff --git a/apps/web/src/components/links/CreateLinksCollectionDialog.tsx b/apps/web/src/components/links/CreateLinksCollectionDialog.tsx index 72a87c98..192bf644 100644 --- a/apps/web/src/components/links/CreateLinksCollectionDialog.tsx +++ b/apps/web/src/components/links/CreateLinksCollectionDialog.tsx @@ -12,10 +12,11 @@ type Props = { open: boolean; onOpenChange: (open: boolean) => void; collection?: { id: string; name: string; description?: string; icon?: string; type?: string } | null; + renameOnly?: boolean; onSaved?: (collection: { id: string; name: string; description?: string; icon?: string; type?: string }) => void; }; -export default function CreateLinksCollectionDialog({ open, onOpenChange, collection, onSaved }: Props) { +export default function CreateLinksCollectionDialog({ open, onOpenChange, collection, renameOnly = false, onSaved }: Props) { const { withAuth } = useAuth(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -53,7 +54,7 @@ export default function CreateLinksCollectionDialog({ open, onOpenChange, collec - {isEditing ? "Edit list" : "Create new list"} + {renameOnly ? "Rename list" : isEditing ? "Edit list" : "Create new list"} setAlert((current) => ({ ...current, open: false }))} /> @@ -93,37 +94,41 @@ export default function CreateLinksCollectionDialog({ open, onOpenChange, collec />
-
- -