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 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/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/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index c264cfff..db9347e3 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"; @@ -117,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 { @@ -156,33 +155,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 +164,13 @@ 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), 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..17e4b437 --- /dev/null +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -0,0 +1,28 @@ +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[], + 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/news.ts b/apps/backend/src/lib/data/news.ts index 86054482..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 ?? [], }; } @@ -1032,6 +1037,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))); @@ -1071,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, @@ -1088,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, @@ -1134,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/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/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/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/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/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/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/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 /> -
- -