) : (
items.map((ad, idx) => {
const adName =
@@ -487,14 +430,7 @@ export function Advertisements() {
{items.length === 0 ? (
-
- |
- {emptyMessage}
- |
-
+ {emptyMessage}
) : (
items.map((ad, idx) => {
const adName =
@@ -527,15 +463,7 @@ export function Advertisements() {
-
- copyToClipboard(e, ad.public_key)
- }
- title="Click to copy"
- >
- {ad.public_key}
-
+
|
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
index ef7a327..18dff39 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
@@ -1,12 +1,19 @@
-import { useCallback, useEffect, useState } from "react";
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
-import QRCode from "react-qr-code";
import { useAppConfig, hasRole } from "@/context/AppConfigContext";
import { apiGet, apiPost, apiPut, apiDelete } from "@/utils/api";
+import { qk, invalidate } from "@/utils/queryKeys";
import { usePageTitle } from "@/hooks/usePageTitle";
import { Loading, ErrorAlert } from "@/components/Alerts";
+import { ConfirmDialog } from "@/components/ConfirmDialog";
+import { EmptyState } from "@/components/EmptyState";
+import { MeshQrCode } from "@/components/MeshQrCode";
+import { Modal } from "@/components/Modal";
+import { PageHeader } from "@/components/PageHeader";
+import { SectionGroup } from "@/components/SectionGroup";
import { IconChannel, IconPlus, IconEdit, IconTrash } from "@/components/icons";
interface Channel {
@@ -36,11 +43,7 @@ type ModalState =
function ChannelQrCode({ channel }: { channel: Channel }) {
if (!channel.key_hex) return null;
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(channel.name)}&secret=${channel.key_hex.toLowerCase()}`;
- return (
-
-
-
- );
+ return ;
}
interface ChannelCardProps {
@@ -167,9 +170,7 @@ function ChannelModal({
: t("channels.add_channel");
return (
-
+
);
}
@@ -268,32 +265,15 @@ function DeleteChannelModal({
const { t } = useTranslation();
return (
-
+ {t("channels.delete_confirm", { name: channel.name })}}
+ confirmLabel={t("common.delete")}
+ cancelLabel={t("common.cancel")}
+ saving={saving}
+ onConfirm={onConfirm}
+ onCancel={onCancel}
+ />
);
}
@@ -305,56 +285,70 @@ export function Channels() {
const isAdmin = hasRole("admin");
usePageTitle("channels.title");
- const [channels, setChannels] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const queryClient = useQueryClient();
+
+ const {
+ data,
+ isLoading: loading,
+ error: queryError,
+ } = useQuery({
+ queryKey: qk.channels.list({}),
+ queryFn: async ({ signal }) => {
+ const resp = await apiGet(
+ "/api/v1/channels",
+ {},
+ { signal },
+ );
+ return resp.items || [];
+ },
+ });
+ const channels = data ?? [];
+ const error = queryError ? queryError.message : null;
const [modal, setModal] = useState(null);
- const [saving, setSaving] = useState(false);
- const fetchChannels = useCallback(async () => {
- try {
- const data = await apiGet("/api/v1/channels");
- setChannels(data.items || []);
- setError(null);
- } catch (e) {
- setError((e as Error).message || t("common.failed_to_load_page"));
- } finally {
- setLoading(false);
- }
- }, [t]);
-
- useEffect(() => {
- fetchChannels();
- }, [fetchChannels]);
-
- const handleSave = async (body: Record) => {
- setSaving(true);
- try {
- if (modal?.type === "edit") {
- await apiPut(`/api/v1/channels/${modal.channel.id}`, body);
+ const saveMutation = useMutation({
+ mutationFn: async ({
+ id,
+ body,
+ }: {
+ id?: string;
+ body: Record;
+ }) => {
+ if (id) {
+ await apiPut(`/api/v1/channels/${id}`, body);
} else {
await apiPost("/api/v1/channels", body);
}
+ },
+ onSuccess: () => invalidate.channels(queryClient),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => apiDelete(`/api/v1/channels/${id}`),
+ onSuccess: () => invalidate.channels(queryClient),
+ });
+
+ const saving = saveMutation.isPending || deleteMutation.isPending;
+
+ const handleSave = async (body: Record) => {
+ try {
+ await saveMutation.mutateAsync({
+ id: modal?.type === "edit" ? modal.channel.id : undefined,
+ body,
+ });
setModal(null);
- await fetchChannels();
} catch (e) {
alert((e as Error).message || "Failed to save channel");
- } finally {
- setSaving(false);
}
};
const handleDeleteConfirm = async () => {
if (modal?.type !== "delete") return;
- setSaving(true);
try {
- await apiDelete(`/api/v1/channels/${modal.channel.id}`);
+ await deleteMutation.mutateAsync(modal.channel.id);
setModal(null);
- await fetchChannels();
} catch (e) {
alert((e as Error).message || "Failed to delete channel");
- } finally {
- setSaving(false);
}
};
@@ -376,12 +370,14 @@ export function Channels() {
return (
-
-
-
- {t("channels.title")}
-
-
+
+
+ {t("channels.title")}
+
+ }
+ />
{error && }
@@ -397,11 +393,11 @@ export function Channels() {
)}
{channels.length === 0 && (
-
+
{t("common.no_entity_found", {
entity: t("entities.channels").toLowerCase(),
})}
-
+
)}
{VISIBILITY_ORDER.map((vis) => {
@@ -409,10 +405,7 @@ export function Channels() {
if (!group || group.length === 0) return null;
return (
-
- {t(`channels.visibility_${vis}`)}
-
-
+
{group.map((ch) => (
))}
-
+
);
})}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx
index ea359b2..eebee13 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/CustomPage.tsx
@@ -3,6 +3,7 @@ import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { ErrorAlert, Loading } from "@/components/Alerts";
+import { Breadcrumbs } from "@/components/Breadcrumbs";
import { useAppConfig } from "@/context/AppConfigContext";
import { apiGet, isAbortError } from "@/utils/api";
@@ -59,6 +60,9 @@ export function CustomPagePage() {
return (
+
(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState (null);
-
- useEffect(() => {
- const controller = new AbortController();
- const { signal } = controller;
- (async () => {
- try {
- const [
- stats,
- recentActivity,
- advertActivity,
- messageActivity,
- nodeCount,
- packetActivity,
- packetBreakdown,
- routesOverview,
- channelsData,
- ] = await Promise.all([
+ const queries = useQueries({
+ queries: [
+ {
+ queryKey: qk.dashboard.stats(),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet("/api/v1/dashboard/stats", {}, { signal }),
+ },
+ {
+ queryKey: qk.dashboard.recent({}),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/recent-activity",
{},
{ signal },
),
+ },
+ {
+ queryKey: qk.dashboard.series("activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/activity",
{ days: 7 },
{ signal },
),
+ },
+ {
+ queryKey: qk.dashboard.series("message-activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/message-activity",
{ days: 7 },
{ signal },
),
+ },
+ {
+ queryKey: qk.dashboard.series("node-count", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/node-count",
{ days: 7 },
{ signal },
),
+ },
+ {
+ queryKey: qk.dashboard.series("packet-activity", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/packet-activity",
{ days: 7 },
{ signal },
),
+ },
+ {
+ queryKey: qk.dashboard.series("packet-breakdown", { days: 7 }),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet(
"/api/v1/dashboard/packet-breakdown",
{ days: 7 },
{ signal },
),
- showRoutes
- ? apiGet(
- "/api/v1/dashboard/routes-overview",
- { days: 7 },
- { signal },
- )
- : Promise.resolve(null),
+ },
+ {
+ queryKey: qk.dashboard.routesOverview(),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ apiGet(
+ "/api/v1/dashboard/routes-overview",
+ { days: 7 },
+ { signal },
+ ),
+ enabled: showRoutes,
+ },
+ {
+ queryKey: qk.channels.list({}),
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
apiGet("/api/v1/channels", {}, { signal }),
- ]);
- setData({
- stats,
- recentActivity,
- advertActivity,
- messageActivity,
- nodeCount,
- packetActivity,
- packetBreakdown,
- routesOverview,
- channelsData,
- });
- setError(null);
- } catch (e) {
- if (isAbortError(e)) return;
- setError(
- e instanceof Error && e.message
- ? e.message
- : t("common.failed_to_load_page"),
- );
- } finally {
- setLoading(false);
- }
- })();
- return () => controller.abort();
- }, [showRoutes, t]);
+ },
+ ],
+ });
+
+ const [
+ statsQ,
+ recentQ,
+ advertQ,
+ messageQ,
+ nodeCountQ,
+ packetActivityQ,
+ packetBreakdownQ,
+ routesOverviewQ,
+ channelsQ,
+ ] = queries;
+
+ const loading = queries.some((q) => q.isLoading);
+ const firstError = queries.find((q) => q.error)?.error ?? null;
+ const error = firstError
+ ? firstError instanceof Error && firstError.message
+ ? firstError.message
+ : t("common.failed_to_load_page")
+ : null;
+
+ const data: DashboardData | null =
+ !loading && !error
+ ? {
+ stats: statsQ.data as DashboardStats,
+ recentActivity: recentQ.data as RecentActivity,
+ advertActivity: (advertQ.data as ActivitySeries | undefined) ?? null,
+ messageActivity:
+ (messageQ.data as ActivitySeries | undefined) ?? null,
+ nodeCount: (nodeCountQ.data as ActivitySeries | undefined) ?? null,
+ packetActivity:
+ (packetActivityQ.data as ActivitySeries | undefined) ?? null,
+ packetBreakdown: packetBreakdownQ.data as PacketBreakdown,
+ routesOverview:
+ (routesOverviewQ.data as RoutesOverview | undefined) ?? null,
+ channelsData: channelsQ.data as ChannelsResponse,
+ }
+ : null;
const channelLabels = useMemo(() => {
if (!data) return new Map();
@@ -400,9 +428,7 @@ export function DashboardPage() {
return (
<>
-
- {t("entities.dashboard")}
-
+
{visibleChartCount > 0 && (
<>
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx
index b08cc0a..942c78b 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Home.tsx
@@ -1,11 +1,5 @@
-import {
- useCallback,
- useEffect,
- useRef,
- useState,
- type ComponentType,
- type SVGProps,
-} from "react";
+import { type ComponentType, type SVGProps } from "react";
+import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
@@ -38,7 +32,8 @@ import { useAppConfig, useFeatures } from "@/context/AppConfigContext";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { usePageTitle } from "@/hooks/usePageTitle";
import type { RadioConfigDisplay } from "@/types/config";
-import { apiGet, isAbortError } from "@/utils/api";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
import { getPageColor } from "@/utils/format";
interface DashboardStats {
@@ -141,17 +136,6 @@ export function HomePage() {
const features = useFeatures();
usePageTitle();
- const [stats, setStats] = useState(null);
- const [advertActivity, setAdvertActivity] = useState(
- null,
- );
- const [messageActivity, setMessageActivity] = useState(
- null,
- );
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const hasDataRef = useRef(false);
-
const networkName = config.network_name || "MeshCore Network";
const logoUrl = config.logo_url || "/static/img/logo.svg";
const logoInvertLight = config.logo_invert_light !== false;
@@ -168,50 +152,46 @@ export function HomePage() {
const showMembersPanel = features.members !== false;
const showRadioPanel = features.radio_config !== false;
- const load = useCallback(
- async (signal?: AbortSignal) => {
- try {
- const [statsData, advertData, messageData] = await Promise.all([
- apiGet("/api/v1/dashboard/stats", {}, { signal }),
- apiGet(
- "/api/v1/dashboard/activity",
- { days: 7 },
- { signal },
- ),
- apiGet(
- "/api/v1/dashboard/message-activity",
- { days: 7 },
- { signal },
- ),
- ]);
- setStats(statsData);
- setAdvertActivity(advertData);
- setMessageActivity(messageData);
- hasDataRef.current = true;
- setError(null);
- } catch (e) {
- if (isAbortError(e)) return;
- if (!hasDataRef.current) {
- setError(
- e instanceof Error && e.message
- ? e.message
- : t("common.failed_to_load_page"),
- );
- }
- } finally {
- setLoading(false);
- }
- },
- [t],
- );
+ const { refetchInterval } = useAutoRefresh();
- useEffect(() => {
- const controller = new AbortController();
- void load(controller.signal);
- return () => controller.abort();
- }, [load]);
+ const statsQuery = useQuery({
+ queryKey: qk.dashboard.stats(),
+ queryFn: ({ signal }) =>
+ apiGet("/api/v1/dashboard/stats", {}, { signal }),
+ refetchInterval,
+ });
+ const advertQuery = useQuery({
+ queryKey: qk.dashboard.series("activity", { days: 7 }),
+ queryFn: ({ signal }) =>
+ apiGet(
+ "/api/v1/dashboard/activity",
+ { days: 7 },
+ { signal },
+ ),
+ refetchInterval,
+ });
+ const messageQuery = useQuery({
+ queryKey: qk.dashboard.series("message-activity", { days: 7 }),
+ queryFn: ({ signal }) =>
+ apiGet(
+ "/api/v1/dashboard/message-activity",
+ { days: 7 },
+ { signal },
+ ),
+ refetchInterval,
+ });
- useAutoRefresh({ onRefresh: load });
+ const stats = statsQuery.data ?? null;
+ const advertActivity = advertQuery.data ?? null;
+ const messageActivity = messageQuery.data ?? null;
+ const loading =
+ statsQuery.isLoading || advertQuery.isLoading || messageQuery.isLoading;
+ const firstError =
+ statsQuery.error ?? advertQuery.error ?? messageQuery.error;
+ const error =
+ !stats && firstError
+ ? firstError.message || t("common.failed_to_load_page")
+ : null;
if (loading) return ;
if (error) return ;
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
index 585c9b5..6baaafa 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet";
@@ -12,10 +13,12 @@ import "leaflet/dist/leaflet.css";
import { useAppConfig } from "@/context/AppConfigContext";
import { usePageTitle } from "@/hooks/usePageTitle";
-import { apiGet, isAbortError } from "@/utils/api";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
import { formatNumber, formatRelativeTime, typeEmoji } from "@/utils/format";
-import { FilterToggle } from "@/components/FilterForm";
+import { FilterToggle, OperatorSelect } from "@/components/FilterForm";
import { ErrorAlert, Loading } from "@/components/Alerts";
+import { PageHeader } from "@/components/PageHeader";
const MAX_BOUNDS_RADIUS_KM = 20;
@@ -326,18 +329,28 @@ export function MapPage() {
usePageTitle("entities.map");
const oidcEnabled = config.oidc_enabled;
- const tz = config.timezone || "";
const operatorRole = config.role_names?.operator || "operator";
- const [mapData, setMapData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
const [category, setCategory] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [operatorFilter, setOperatorFilter] = useState("");
const [showLabels, setShowLabels] = useState(false);
+ const mapQuery = useQuery({
+ queryKey: qk.map.data({ adopted_by: operatorFilter || undefined }),
+ queryFn: ({ signal }) => {
+ const params: Record = {};
+ if (operatorFilter) params.adopted_by = operatorFilter;
+ return apiGet("/map/data", params, { signal });
+ },
+ });
+ const mapData = mapQuery.data ?? null;
+ const loading = mapQuery.isLoading;
+ const error = mapQuery.error
+ ? mapQuery.error.message || t("common.failed_to_load_page")
+ : null;
+
const operatorProfiles = useMemo(
() =>
(mapData?.profiles || [])
@@ -350,23 +363,6 @@ export function MapPage() {
[mapData, operatorRole],
);
- useEffect(() => {
- const ac = new AbortController();
- const params: Record = {};
- if (operatorFilter) params.adopted_by = operatorFilter;
- apiGet("/map/data", params, { signal: ac.signal })
- .then((data) => {
- setMapData(data);
- setError(null);
- })
- .catch((e) => {
- if (isAbortError(e)) return;
- setError((e as Error).message || t("common.failed_to_load_page"));
- })
- .finally(() => setLoading(false));
- return () => ac.abort();
- }, [operatorFilter, t]);
-
const allNodes = useMemo(() => mapData?.nodes ?? [], [mapData]);
const filteredNodes = useMemo(
@@ -431,24 +427,18 @@ export function MapPage() {
return (
-
- {t("entities.map")}
-
- {tz && tz !== "UTC" && (
- {tz}
- )}
- {countBadgeText}
- {showFilteredBadge && (
-
- {t("common.shown", { count: formatNumber(filteredCount) })}
-
- )}
- setFilterOpen((open) => !open)}
- />
-
-
+
+ {countBadgeText}
+ {showFilteredBadge && (
+
+ {t("common.shown", { count: formatNumber(filteredCount) })}
+
+ )}
+ setFilterOpen((open) => !open)}
+ />
+
{filterOpen && (
@@ -485,20 +475,11 @@ export function MapPage() {
-
+ profiles={operatorProfiles}
+ />
)}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
index 16f3ac1..f6ff0a2 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
@@ -1,16 +1,19 @@
import {
- useEffect,
- useState,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { Link, useNavigate } from "react-router";
+import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
-import { apiGet, isAbortError } from "@/utils/api";
-import { formatNumber } from "@/utils/format";
+import { apiGet } from "@/utils/api";
+import { qk } from "@/utils/queryKeys";
+import { formatNumber, resolveNodeName } from "@/utils/format";
import { Loading, ErrorAlert } from "@/components/Alerts";
+import { CallsignBadge, RoleBadge } from "@/components/Badges";
+import { EmptyState } from "@/components/EmptyState";
+import { PageHeader } from "@/components/PageHeader";
import { IconAntenna, IconUsers } from "@/components/icons";
import { usePageTitle } from "@/hooks/usePageTitle";
@@ -58,18 +61,12 @@ function ProfileTile({ profile }: { profile: MemberProfile }) {
{profile.name || t("common.unnamed")}
- {profile.callsign && (
-
- {profile.callsign}
-
- )}
+ {profile.callsign && }
{profile.roles && profile.roles.length > 0 && (
{profile.roles.map((role) => (
-
- {role}
-
+
))}
)}
@@ -101,7 +98,7 @@ function ProfileTile({ profile }: { profile: MemberProfile }) {
{profile.adopted_nodes && profile.adopted_nodes.length > 0 && (
{profile.adopted_nodes.map((node) => {
- const label = node.name || node.public_key.slice(0, 12) + "...";
+ const label = resolveNodeName(node);
return (
(null);
- const [error, setError] = useState(null);
- useEffect(() => {
- const controller = new AbortController();
- apiGet(
- "/api/v1/user/profiles",
- { limit: 500 },
- { signal: controller.signal },
- )
- .then((resp) => setProfiles(resp.items || []))
- .catch((e) => {
- if (!isAbortError(e)) {
- setError((e as Error).message || t("common.failed_to_load_page"));
- }
- });
- return () => controller.abort();
- }, [t]);
+ const { data, error: queryError } = useQuery({
+ queryKey: qk.profiles.list({ limit: 500 }),
+ queryFn: async ({ signal }) => {
+ const resp = await apiGet(
+ "/api/v1/user/profiles",
+ { limit: 500 },
+ { signal },
+ );
+ return resp.items || [];
+ },
+ });
+ const profiles = data ?? null;
+ const error = queryError ? queryError.message : null;
if (error) return ;
if (profiles === null) return ;
@@ -187,13 +180,11 @@ export function Members() {
if (visible.length === 0) {
return (
<>
-
- {t("entities.members")}
-
-
+
+
{t("members_page.empty_state")}
{t("members_page.empty_description")}
-
+
>
);
}
@@ -212,15 +203,14 @@ export function Members() {
return (
<>
-
- {t("entities.members")}
+
{t("common.count_entity", {
count: formatNumber(operators.length + members.length),
entity: t("entities.members").toLowerCase(),
})}
-
+
{
total?: number;
}
-function autoSubmit(e: React.ChangeEvent) {
- e.currentTarget.form?.requestSubmit();
-}
-
function parseSenderFromText(text: string | null): {
sender: string | null;
text: string;
@@ -243,21 +248,7 @@ export function Messages() {
typeof config.spam_score_threshold === "number"
? config.spam_score_threshold
: 0.65;
- const tz = config.timezone || "";
- const [items, setItems] = useState(null);
- const [total, setTotal] = useState(null);
- const [error, setError] = useState(null);
- const [sortedAreas, setSortedAreas] = useState([]);
- const [builtinLabels, setBuiltinLabels] = useState |