mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 00:47:17 +00:00
perf: switch to SSE dispatched changes
Previously we would use naive revalidators which would invalidate EVERY SINGLE action loader every 3 seconds, resulting in several fetches. It would also bubble fetches across the layout actions into the individual pages. This new approach selectively has live stores of resources which then poll for changes on the server side and then dispatches updates to the client via a new /events/live SSE endpoint.
This commit is contained in:
@@ -1,27 +0,0 @@
|
|||||||
import { useProgressBar } from "react-aria";
|
|
||||||
|
|
||||||
import cn from "~/utils/cn";
|
|
||||||
|
|
||||||
export interface ProgressBarProps {
|
|
||||||
isVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar(props: ProgressBarProps) {
|
|
||||||
const { isVisible } = props;
|
|
||||||
const { progressBarProps } = useProgressBar({
|
|
||||||
label: "Loading...",
|
|
||||||
isIndeterminate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...progressBarProps}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
className={cn(
|
|
||||||
"fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0",
|
|
||||||
"bg-mist-950 dark:bg-mist-50",
|
|
||||||
isVisible && "animate-loading opacity-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ export default function Link(props: LinkProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterLink to={props.to} className={props.className}>
|
<RouterLink to={props.to} prefetch="intent" className={props.className}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// oxlint-disable react/no-multi-comp
|
|
||||||
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
||||||
import type { ComponentProps, JSX, ReactNode } from "react";
|
import type { ComponentProps, JSX, ReactNode } from "react";
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -1,9 +1,10 @@
|
|||||||
import { Outlet, redirect } from "react-router";
|
import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||||
|
|
||||||
import { ErrorBanner } from "~/components/error-banner";
|
import { ErrorBanner } from "~/components/error-banner";
|
||||||
import StatusBanner from "~/components/status-banner";
|
import StatusBanner from "~/components/status-banner";
|
||||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||||
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import log from "~/utils/log";
|
import log from "~/utils/log";
|
||||||
|
|
||||||
@@ -11,6 +12,24 @@ import type { Route } from "./+types/app";
|
|||||||
import Footer from "./footer";
|
import Footer from "./footer";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
|
|
||||||
|
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||||
|
currentUrl,
|
||||||
|
nextUrl,
|
||||||
|
formAction,
|
||||||
|
defaultShouldRevalidate,
|
||||||
|
}) => {
|
||||||
|
if (formAction) {
|
||||||
|
return defaultShouldRevalidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed
|
||||||
|
if (currentUrl.href === nextUrl.href) {
|
||||||
|
return defaultShouldRevalidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||||
try {
|
try {
|
||||||
const principal = await context.auth.require(request);
|
const principal = await context.auth.require(request);
|
||||||
@@ -52,8 +71,8 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
|||||||
// stale link so the user gets prompted to re-link.
|
// stale link so the user gets prompted to re-link.
|
||||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||||
try {
|
try {
|
||||||
const hsUsers = await api.getUsers();
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
if (!hsUsers.some((u) => u.id === principal.user.headscaleUserId)) {
|
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
|
||||||
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
await context.auth.unlinkHeadscaleUser(principal.user.id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+2
-10
@@ -1,9 +1,8 @@
|
|||||||
import type { LinksFunction, MetaFunction } from "react-router";
|
import type { LinksFunction, MetaFunction } from "react-router";
|
||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||||
import "@fontsource-variable/inter";
|
import "@fontsource-variable/inter";
|
||||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||||
|
|
||||||
import ProgressBar from "~/components/ProgressBar";
|
|
||||||
import ToastProvider from "~/components/ToastProvider";
|
import ToastProvider from "~/components/ToastProvider";
|
||||||
import { LiveDataProvider } from "~/utils/live-data";
|
import { LiveDataProvider } from "~/utils/live-data";
|
||||||
import { useToastQueue } from "~/utils/toast";
|
import { useToastQueue } from "~/utils/toast";
|
||||||
@@ -60,12 +59,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const nav = useNavigation();
|
return <Outlet />;
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ProgressBar isVisible={nav.state === "loading"} />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default [
|
|||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
||||||
|
...prefix("/events", [route("/live", "routes/util/live.ts")]),
|
||||||
|
|
||||||
// Authentication Routes
|
// Authentication Routes
|
||||||
route("/login", "routes/auth/login/page.tsx"),
|
route("/login", "routes/auth/login/page.tsx"),
|
||||||
|
|||||||
+7
-4
@@ -10,6 +10,7 @@ import Button from "~/components/button";
|
|||||||
import Card from "~/components/Card";
|
import Card from "~/components/Card";
|
||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
import LinkAccount from "~/layout/link-account";
|
import LinkAccount from "~/layout/link-account";
|
||||||
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import toast from "~/utils/toast";
|
import toast from "~/utils/toast";
|
||||||
@@ -33,10 +34,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
let headscaleUsers: { id: string; name: string }[] = [];
|
let headscaleUsers: { id: string; name: string }[] = [];
|
||||||
try {
|
try {
|
||||||
const [apiUsers, claimed] = await Promise.all([
|
const [usersSnap, claimed] = await Promise.all([
|
||||||
api.getUsers(),
|
context.hsLive.get(usersResource, api),
|
||||||
context.auth.claimedHeadscaleUserIds(),
|
context.auth.claimedHeadscaleUserIds(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const apiUsers = usersSnap.data;
|
||||||
headscaleUsers = apiUsers
|
headscaleUsers = apiUsers
|
||||||
.filter((u) => !claimed.has(u.id))
|
.filter((u) => !claimed.has(u.id))
|
||||||
.map((u) => ({ id: u.id, name: getUserDisplayName(u) }));
|
.map((u) => ({ id: u.id, name: getUserDisplayName(u) }));
|
||||||
@@ -68,8 +71,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
let linkedUserName: string | undefined;
|
let linkedUserName: string | undefined;
|
||||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||||
try {
|
try {
|
||||||
const users = await api.getUsers();
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
|
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
|
||||||
linkedUserName = hsUser?.name;
|
linkedUserName = hsUser?.name;
|
||||||
} catch {
|
} catch {
|
||||||
// API unavailable, skip linked user resolution
|
// API unavailable, skip linked user resolution
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { data, redirect } from "react-router";
|
import { data, redirect } from "react-router";
|
||||||
|
|
||||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
|
import { nodesResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
|
|
||||||
import type { Route } from "./+types/machine";
|
import type { Route } from "./+types/machine";
|
||||||
@@ -43,6 +44,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const node = await api.registerNode(user, registrationKey);
|
const node = await api.registerNode(user, registrationKey);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return redirect(`/machines/${node.id}`);
|
return redirect(`/machines/${node.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,16 +80,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
|
|
||||||
const name = String(formData.get("name"));
|
const name = String(formData.get("name"));
|
||||||
await api.renameNode(nodeId, name);
|
await api.renameNode(nodeId, name);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return { message: "Machine renamed" };
|
return { message: "Machine renamed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "delete": {
|
case "delete": {
|
||||||
await api.deleteNode(nodeId);
|
await api.deleteNode(nodeId);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return redirect("/machines");
|
return redirect("/machines");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "expire": {
|
case "expire": {
|
||||||
await api.expireNode(nodeId);
|
await api.expireNode(nodeId);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return { message: "Machine expired" };
|
return { message: "Machine expired" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +110,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return { success: true as const, message: "Tags updated" };
|
return { success: true as const, message: "Tags updated" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||||
@@ -169,6 +175,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.approveNodeRoutes(nodeId, newApproved);
|
await api.approveNodeRoutes(nodeId, newApproved);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return { message: "Routes updated" };
|
return { message: "Routes updated" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +188,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.setNodeUser(nodeId, user);
|
await api.setNodeUser(nodeId, user);
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
return { message: "Machine reassigned" };
|
return { message: "Machine reassigned" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Chip from "~/components/Chip";
|
|||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
import StatusCircle from "~/components/StatusCircle";
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||||
@@ -40,7 +41,12 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
const api = context.hsApi.getRuntimeClient(
|
const api = context.hsApi.getRuntimeClient(
|
||||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||||
);
|
);
|
||||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
|
context.hsLive.get(nodesResource, api),
|
||||||
|
context.hsLive.get(usersResource, api),
|
||||||
|
]);
|
||||||
|
const nodes = nodesSnap.data;
|
||||||
|
const users = usersSnap.data;
|
||||||
const node = nodes.find((node) => node.id === params.id);
|
const node = nodes.find((node) => node.id === params.id);
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Input from "~/components/Input";
|
|||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
import PageError from "~/components/page-error";
|
import PageError from "~/components/page-error";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||||
@@ -29,7 +30,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const api = context.hsApi.getRuntimeClient(
|
const api = context.hsApi.getRuntimeClient(
|
||||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||||
);
|
);
|
||||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
|
context.hsLive.get(nodesResource, api),
|
||||||
|
context.hsLive.get(usersResource, api),
|
||||||
|
]);
|
||||||
|
const nodes = nodesSnap.data;
|
||||||
|
const users = usersSnap.data;
|
||||||
|
|
||||||
let magic: string | undefined;
|
let magic: string | undefined;
|
||||||
if (context.hs.readable()) {
|
if (context.hs.readable()) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from "~/components/link";
|
|||||||
import Notice from "~/components/Notice";
|
import Notice from "~/components/Notice";
|
||||||
import Select from "~/components/Select";
|
import Select from "~/components/Select";
|
||||||
import TableList from "~/components/TableList";
|
import TableList from "~/components/TableList";
|
||||||
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { PreAuthKey } from "~/types";
|
import type { PreAuthKey } from "~/types";
|
||||||
import type { User } from "~/types/User";
|
import type { User } from "~/types/User";
|
||||||
@@ -22,7 +23,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
|
|
||||||
const users = await api.getUsers();
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
|
const users = usersSnap.data;
|
||||||
|
|
||||||
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
||||||
let missing: { user: User; error: unknown }[] = [];
|
let missing: { user: User; error: unknown }[] = [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import PageError from "~/components/page-error";
|
import PageError from "~/components/page-error";
|
||||||
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities, Roles } from "~/server/web/roles";
|
import { Capabilities, Roles } from "~/server/web/roles";
|
||||||
import type { Role } from "~/server/web/roles";
|
import type { Role } from "~/server/web/roles";
|
||||||
import type { Machine, User } from "~/types";
|
import type { Machine, User } from "~/types";
|
||||||
@@ -55,7 +56,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
try {
|
try {
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
[nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
|
context.hsLive.get(nodesResource, api),
|
||||||
|
context.hsLive.get(usersResource, api),
|
||||||
|
]);
|
||||||
|
nodes = nodesSnap.data;
|
||||||
|
apiUsers = usersSnap.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
|
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
|
||||||
apiError =
|
apiError =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
|
|
||||||
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import type { Role } from "~/server/web/roles";
|
import type { Role } from "~/server/web/roles";
|
||||||
@@ -38,6 +39,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.createUser(name, email, displayName);
|
await api.createUser(name, email, displayName);
|
||||||
|
await context.hsLive.refresh(usersResource, api);
|
||||||
return { message: "User created successfully" };
|
return { message: "User created successfully" };
|
||||||
}
|
}
|
||||||
case "delete_user": {
|
case "delete_user": {
|
||||||
@@ -49,6 +51,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.deleteUser(userId);
|
await api.deleteUser(userId);
|
||||||
|
await context.hsLive.refresh(usersResource, api);
|
||||||
return { message: "User deleted successfully" };
|
return { message: "User deleted successfully" };
|
||||||
}
|
}
|
||||||
case "rename_user": {
|
case "rename_user": {
|
||||||
@@ -72,6 +75,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.renameUser(userId, newName);
|
await api.renameUser(userId, newName);
|
||||||
|
await context.hsLive.refresh(usersResource, api);
|
||||||
return { message: "User renamed successfully" };
|
return { message: "User renamed successfully" };
|
||||||
}
|
}
|
||||||
case "reassign_user": {
|
case "reassign_user": {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
|
import log from "~/utils/log";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/live";
|
||||||
|
|
||||||
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
|
const principal = await context.auth.require(request);
|
||||||
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
|
|
||||||
|
// Ensure resources are loaded before streaming
|
||||||
|
await Promise.all([
|
||||||
|
context.hsLive.get(nodesResource, api),
|
||||||
|
context.hsLive.get(usersResource, api),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const send = (event: string, data: unknown) => {
|
||||||
|
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const versions = context.hsLive.getVersions();
|
||||||
|
log.debug("sse", "Client connected, sending hello with versions: %o", versions);
|
||||||
|
send("hello", versions);
|
||||||
|
|
||||||
|
const unsubscribe = context.hsLive.subscribe((resource, version) => {
|
||||||
|
log.debug("sse", "Sending change event: %s v%s", resource, version);
|
||||||
|
send("changed", { resource, version });
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||||
|
} catch {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
request.signal.addEventListener("abort", () => {
|
||||||
|
log.debug("sse", "Client disconnected");
|
||||||
|
unsubscribe();
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq, isNotNull } from "drizzle-orm";
|
import { eq, isNotNull } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { nodesResource } from "~/server/headscale/live-store";
|
||||||
import log from "~/utils/log";
|
import log from "~/utils/log";
|
||||||
|
|
||||||
import type { Route } from "../../layout/+types/app";
|
import type { Route } from "../../layout/+types/app";
|
||||||
@@ -45,4 +46,8 @@ export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises.map((p) => p()));
|
await Promise.all(promises.map((p) => p()));
|
||||||
|
|
||||||
|
if (toPrune.length > 0) {
|
||||||
|
await context.hsLive.refresh(nodesResource, api);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+264
-276
@@ -1,14 +1,17 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from "node:crypto";
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from "node:fs/promises";
|
||||||
import { dereference } from '@readme/openapi-parser';
|
|
||||||
import type { OpenAPIV2 } from 'openapi-types';
|
import { dereference } from "@readme/openapi-parser";
|
||||||
import { data } from 'react-router';
|
import type { OpenAPIV2 } from "openapi-types";
|
||||||
import { Agent, type Dispatcher, request } from 'undici';
|
import { data } from "react-router";
|
||||||
import log from '~/utils/log';
|
import { Agent, type Dispatcher, request } from "undici";
|
||||||
import endpointSets, { RuntimeApiClient } from './endpoints';
|
|
||||||
import { undiciToFriendlyError } from './error';
|
import log from "~/utils/log";
|
||||||
import { HeadscaleAPIError, isApiError } from './error-client';
|
|
||||||
import { detectApiVersion, isAtLeast, type Version } from './version';
|
import endpointSets, { RuntimeApiClient } from "./endpoints";
|
||||||
|
import { undiciToFriendlyError } from "./error";
|
||||||
|
import { HeadscaleAPIError, isApiError } from "./error-client";
|
||||||
|
import { detectApiVersion, isAtLeast, type Version } from "./version";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A low-level composed interface for interacting with the Headscale API.
|
* A low-level composed interface for interacting with the Headscale API.
|
||||||
@@ -19,82 +22,82 @@ import { detectApiVersion, isAtLeast, type Version } from './version';
|
|||||||
* determine the implementations of API methods when requested for use.
|
* determine the implementations of API methods when requested for use.
|
||||||
*/
|
*/
|
||||||
export interface HeadscaleApiInterface {
|
export interface HeadscaleApiInterface {
|
||||||
/**
|
/**
|
||||||
* The underlying Undici agent used for making requests.
|
* The underlying Undici agent used for making requests.
|
||||||
*/
|
*/
|
||||||
undiciAgent: Agent;
|
undiciAgent: Agent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base URL of the Headscale API.
|
* The base URL of the Headscale API.
|
||||||
*/
|
*/
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
||||||
* This is used to determine which implementations of API methods to use.
|
* This is used to determine which implementations of API methods to use.
|
||||||
*/
|
*/
|
||||||
openapiHashes: Record<string, string> | null;
|
openapiHashes: Record<string, string> | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The detected API version of the connected Headscale instance.
|
* The detected API version of the connected Headscale instance.
|
||||||
*/
|
*/
|
||||||
apiVersion: Version;
|
apiVersion: Version;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a runtime API client for the given API key.
|
* Retrieves a runtime API client for the given API key.
|
||||||
*
|
*
|
||||||
* @param apiKey The API key to use for authentication.
|
* @param apiKey The API key to use for authentication.
|
||||||
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
||||||
*/
|
*/
|
||||||
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A set of helper methods made available to API method implementations.
|
* A set of helper methods made available to API method implementations.
|
||||||
* The idea is to make interacting with the API easier by providing
|
* The idea is to make interacting with the API easier by providing
|
||||||
* common functionality that can be reused across multiple methods.
|
* common functionality that can be reused across multiple methods.
|
||||||
*/
|
*/
|
||||||
clientHelpers: {
|
clientHelpers: {
|
||||||
/**
|
/**
|
||||||
* Checks if the connected Headscale instance's API version
|
* Checks if the connected Headscale instance's API version
|
||||||
* is at least the specified version.
|
* is at least the specified version.
|
||||||
*
|
*
|
||||||
* @param version The version to check against.
|
* @param version The version to check against.
|
||||||
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
isAtleast(version: Version): boolean;
|
isAtleast(version: Version): boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
||||||
* This method is used internally by API method implementations
|
* This method is used internally by API method implementations
|
||||||
* to make requests to the Headscale API.
|
* to make requests to the Headscale API.
|
||||||
*
|
*
|
||||||
* @param path The API path to request.
|
* @param path The API path to request.
|
||||||
* @param options Optional request options.
|
* @param options Optional request options.
|
||||||
* @returns A promise that resolves to the response data.
|
* @returns A promise that resolves to the response data.
|
||||||
*/
|
*/
|
||||||
rawFetch(
|
rawFetch(
|
||||||
path: string,
|
path: string,
|
||||||
options?: Partial<Dispatcher.RequestOptions>,
|
options?: Partial<Dispatcher.RequestOptions>,
|
||||||
): Promise<Dispatcher.ResponseData>;
|
): Promise<Dispatcher.ResponseData>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a typed API fetch request to the Headscale API.
|
* Makes a typed API fetch request to the Headscale API.
|
||||||
* This method is used internally by API method implementations
|
* This method is used internally by API method implementations
|
||||||
* to make requests to the Headscale API and parse the response.
|
* to make requests to the Headscale API and parse the response.
|
||||||
*
|
*
|
||||||
* @param method The HTTP method to use.
|
* @param method The HTTP method to use.
|
||||||
* @param apiPath The API path to request.
|
* @param apiPath The API path to request.
|
||||||
* @param apiKey The API key to use for authentication.
|
* @param apiKey The API key to use for authentication.
|
||||||
* @param bodyOrQuery Optional body or query parameters.
|
* @param bodyOrQuery Optional body or query parameters.
|
||||||
* @returns A promise that resolves to the typed response data.
|
* @returns A promise that resolves to the typed response data.
|
||||||
*/
|
*/
|
||||||
apiFetch<T>(
|
apiFetch<T>(
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||||
apiPath: `v1/${string}`,
|
apiPath: `v1/${string}`,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
bodyOrQuery?: Record<string, unknown>,
|
bodyOrQuery?: Record<string, unknown>,
|
||||||
): Promise<T>;
|
): Promise<T>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,183 +108,168 @@ export interface HeadscaleApiInterface {
|
|||||||
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
||||||
*/
|
*/
|
||||||
export async function createHeadscaleInterface(
|
export async function createHeadscaleInterface(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
certPath?: string,
|
certPath?: string,
|
||||||
): Promise<HeadscaleApiInterface> {
|
): Promise<HeadscaleApiInterface> {
|
||||||
const undiciAgent = await createUndiciAgent(certPath);
|
const undiciAgent = await createUndiciAgent(certPath);
|
||||||
let openapiHashes: Record<string, string> | null = null;
|
let openapiHashes: Record<string, string> | null = null;
|
||||||
let apiVersion: Version;
|
let apiVersion: Version;
|
||||||
|
|
||||||
const rawFetch = async (
|
const rawFetch = async (
|
||||||
url: string,
|
url: string,
|
||||||
options?: Partial<Dispatcher.RequestOptions>,
|
options?: Partial<Dispatcher.RequestOptions>,
|
||||||
): Promise<Dispatcher.ResponseData> => {
|
): Promise<Dispatcher.ResponseData> => {
|
||||||
const method = options?.method ?? 'GET';
|
const method = options?.method ?? "GET";
|
||||||
log.debug('api', '%s %s', method, url);
|
log.debug("api", "%s %s", method, url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await request(new URL(url, baseUrl), {
|
const res = await request(new URL(url, baseUrl), {
|
||||||
dispatcher: undiciAgent,
|
dispatcher: undiciAgent,
|
||||||
headers: {
|
headers: {
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
Accept: 'application/json',
|
Accept: "application/json",
|
||||||
'User-Agent': `Headplane/${__VERSION__}`,
|
"User-Agent": `Headplane/${__VERSION__}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
body: options?.body,
|
body: options?.body,
|
||||||
method,
|
method,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||||
throw data(errorBody, {
|
throw data(errorBody, {
|
||||||
status: 502,
|
status: 502,
|
||||||
statusText: 'Bad Gateway',
|
statusText: "Bad Gateway",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiFetch = async <T>(
|
const apiFetch = async <T>(
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||||
apiPath: `v1/${string}`,
|
apiPath: `v1/${string}`,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
bodyOrQuery?: Record<string, unknown>,
|
bodyOrQuery?: Record<string, unknown>,
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
let url = `/api/${apiPath}`;
|
let url = `/api/${apiPath}`;
|
||||||
const options: Partial<Dispatcher.RequestOptions> = {
|
const options: Partial<Dispatcher.RequestOptions> = {
|
||||||
method: method,
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (bodyOrQuery) {
|
if (bodyOrQuery) {
|
||||||
if (method === 'GET' || method === 'DELETE') {
|
if (method === "GET" || method === "DELETE") {
|
||||||
// Filter out undefined values
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
params.append(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
if ([...params.keys()].length > 0) {
|
||||||
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
url += `?${params.toString()}`;
|
||||||
if (value !== undefined) {
|
}
|
||||||
params.append(key, String(value));
|
} else {
|
||||||
}
|
options.body = JSON.stringify(bodyOrQuery);
|
||||||
}
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ([...params.keys()].length > 0) {
|
const res = await rawFetch(url, options);
|
||||||
url += `?${params.toString()}`;
|
if (res.statusCode >= 400) {
|
||||||
}
|
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
|
||||||
} else {
|
const rawData = await res.body.text();
|
||||||
options.body = JSON.stringify(bodyOrQuery);
|
const jsonData = (() => {
|
||||||
options.headers = {
|
try {
|
||||||
...options.headers,
|
return JSON.parse(rawData) as Record<string, unknown>;
|
||||||
'Content-Type': 'application/json',
|
} catch {
|
||||||
};
|
return null;
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
const res = await rawFetch(url, options);
|
throw data(
|
||||||
if (res.statusCode >= 400) {
|
{
|
||||||
log.debug(
|
requestUrl: `${method} ${apiPath}`,
|
||||||
'api',
|
statusCode: res.statusCode,
|
||||||
'%s %s failed with status %d',
|
rawData,
|
||||||
method,
|
data: jsonData,
|
||||||
apiPath,
|
} satisfies HeadscaleAPIError,
|
||||||
res.statusCode,
|
{ status: 502, statusText: "Bad Gateway" },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rawData = await res.body.text();
|
return res.body.json() as Promise<T>;
|
||||||
const jsonData = (() => {
|
};
|
||||||
try {
|
|
||||||
return JSON.parse(rawData) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
throw data(
|
/**
|
||||||
{
|
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
||||||
requestUrl: `${method} ${apiPath}`,
|
* This is used to determine which implementations of API methods to use.
|
||||||
statusCode: res.statusCode,
|
*
|
||||||
rawData,
|
* @returns A promise that resolves to the OpenAPI operation hashes.
|
||||||
data: jsonData,
|
*/
|
||||||
} satisfies HeadscaleAPIError,
|
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
||||||
{
|
try {
|
||||||
status: 502,
|
const res = await rawFetch("/swagger/v1/openapiv2.json");
|
||||||
statusText: 'Bad Gateway',
|
if (res.statusCode !== 200) {
|
||||||
},
|
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.body.json() as Promise<T>;
|
const body = await res.body.json();
|
||||||
};
|
const spec = await dereference(body as OpenAPIV2.Document);
|
||||||
|
const hashes = generateSpecHashes(spec);
|
||||||
|
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
|
||||||
|
return hashes;
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
return null;
|
||||||
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
}
|
||||||
* This is used to determine which implementations of API methods to use.
|
}
|
||||||
*
|
|
||||||
* @returns A promise that resolves to the OpenAPI operation hashes.
|
|
||||||
*/
|
|
||||||
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
|
||||||
try {
|
|
||||||
const res = await rawFetch('/swagger/v1/openapiv2.json');
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await res.body.json();
|
const isAtleast = (version: Version): boolean => {
|
||||||
const spec = await dereference(body as OpenAPIV2.Document);
|
return isAtLeast(apiVersion, version);
|
||||||
const hashes = generateSpecHashes(spec);
|
};
|
||||||
log.debug(
|
|
||||||
'api',
|
|
||||||
'OpenAPI hashes updated (%d endpoints)',
|
|
||||||
Object.keys(hashes).length,
|
|
||||||
);
|
|
||||||
return hashes;
|
|
||||||
} catch (error) {
|
|
||||||
if (isApiError(error)) {
|
|
||||||
log.debug('api', 'Failed to fetch OpenAPI spec: %d', error.statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
openapiHashes = await fetchAndHashOpenapi();
|
||||||
}
|
apiVersion = detectApiVersion(openapiHashes);
|
||||||
}
|
|
||||||
|
|
||||||
const isAtleast = (version: Version): boolean => {
|
setInterval(async () => {
|
||||||
return isAtLeast(apiVersion, version);
|
const hashes = await fetchAndHashOpenapi();
|
||||||
};
|
if (hashes) {
|
||||||
|
openapiHashes = hashes;
|
||||||
|
apiVersion = detectApiVersion(openapiHashes);
|
||||||
|
}
|
||||||
|
}, 60_000); // every 60 seconds
|
||||||
|
|
||||||
openapiHashes = await fetchAndHashOpenapi();
|
return {
|
||||||
apiVersion = detectApiVersion(openapiHashes);
|
undiciAgent,
|
||||||
|
baseUrl,
|
||||||
setInterval(async () => {
|
openapiHashes,
|
||||||
const hashes = await fetchAndHashOpenapi();
|
apiVersion,
|
||||||
if (hashes) {
|
getRuntimeClient: (apiKey: string) => {
|
||||||
openapiHashes = hashes;
|
return endpointSets(
|
||||||
apiVersion = detectApiVersion(openapiHashes);
|
{
|
||||||
}
|
rawFetch,
|
||||||
}, 60_000); // every 60 seconds
|
apiFetch,
|
||||||
|
isAtleast,
|
||||||
return {
|
},
|
||||||
undiciAgent,
|
apiKey,
|
||||||
baseUrl,
|
);
|
||||||
openapiHashes,
|
},
|
||||||
apiVersion,
|
clientHelpers: {
|
||||||
getRuntimeClient: (apiKey: string) =>
|
rawFetch,
|
||||||
endpointSets(
|
apiFetch,
|
||||||
{
|
isAtleast,
|
||||||
rawFetch,
|
},
|
||||||
apiFetch,
|
};
|
||||||
isAtleast,
|
|
||||||
},
|
|
||||||
apiKey,
|
|
||||||
),
|
|
||||||
clientHelpers: {
|
|
||||||
rawFetch,
|
|
||||||
apiFetch,
|
|
||||||
isAtleast,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,50 +279,50 @@ export async function createHeadscaleInterface(
|
|||||||
* @returns A promise that resolves to an `Agent` instance.
|
* @returns A promise that resolves to an `Agent` instance.
|
||||||
*/
|
*/
|
||||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||||
if (!certPath) {
|
if (!certPath) {
|
||||||
return new Agent();
|
return new Agent();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug('config', 'Loading certificate from %s', certPath);
|
log.debug("config", "Loading certificate from %s", certPath);
|
||||||
const data = await readFile(certPath, 'utf8');
|
const data = await readFile(certPath, "utf8");
|
||||||
|
|
||||||
log.info('config', 'Using certificate from %s', certPath);
|
log.info("config", "Using certificate from %s", certPath);
|
||||||
return new Agent({ connect: { ca: data.trim() } });
|
return new Agent({ connect: { ca: data.trim() } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
|
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||||
log.debug('config', 'Error Details: %o', error);
|
log.debug("config", "Error Details: %o", error);
|
||||||
return new Agent();
|
return new Agent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
||||||
const hashes: Record<string, string> = {};
|
const hashes: Record<string, string> = {};
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const [path, item] of Object.entries(spec.paths)) {
|
for (const [path, item] of Object.entries(spec.paths)) {
|
||||||
for (const [method, operation] of Object.entries(item)) {
|
for (const [method, operation] of Object.entries(item)) {
|
||||||
if (typeof operation !== 'object') {
|
if (typeof operation !== "object") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||||
const raw = JSON.stringify(
|
const raw = JSON.stringify(
|
||||||
{
|
{
|
||||||
path,
|
path,
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
parameters,
|
parameters,
|
||||||
responses,
|
responses,
|
||||||
},
|
},
|
||||||
Object.keys({ path, method, parameters, responses }).sort(),
|
Object.keys({ path, method, parameters, responses }).sort(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
|
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||||
seen.add(final);
|
seen.add(final);
|
||||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashes;
|
return hashes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import log from "~/utils/log";
|
||||||
|
|
||||||
|
import type { RuntimeApiClient } from "./api/endpoints";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a resource that can be fetched and polled by the live store.
|
||||||
|
*/
|
||||||
|
export interface ResourceDefinition<T> {
|
||||||
|
/**
|
||||||
|
* A unique key to identify the resource
|
||||||
|
*/
|
||||||
|
readonly key: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often to poll for changes (in milliseconds)
|
||||||
|
*/
|
||||||
|
readonly pollInterval: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A callback to fire to get the latest data for this resource
|
||||||
|
*/
|
||||||
|
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to define a resource with proper typing to be used
|
||||||
|
* as a keying input for the live store.
|
||||||
|
* @param key A unique key to identify the resource
|
||||||
|
* @param config The resource configuration
|
||||||
|
*/
|
||||||
|
export function defineResource<T>(
|
||||||
|
key: string,
|
||||||
|
config: Omit<ResourceDefinition<T>, "key">,
|
||||||
|
): ResourceDefinition<T> {
|
||||||
|
return { key, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodesResource = defineResource("nodes", {
|
||||||
|
pollInterval: 5_000,
|
||||||
|
fetch: (api) => api.getNodes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usersResource = defineResource("users", {
|
||||||
|
pollInterval: 15_000,
|
||||||
|
fetch: (api) => api.getUsers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Snapshot<T> {
|
||||||
|
data: T;
|
||||||
|
version: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangeListener = (resourceKey: string, version: string) => void;
|
||||||
|
|
||||||
|
export interface LiveStore {
|
||||||
|
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
|
||||||
|
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
|
||||||
|
getVersions(): Record<string, string>;
|
||||||
|
subscribe(listener: ChangeListener): () => void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveStore {
|
||||||
|
const snapshots = new Map<string, Snapshot<unknown>>();
|
||||||
|
const serializedCache = new Map<string, string>();
|
||||||
|
const listeners = new Set<ChangeListener>();
|
||||||
|
const intervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
let storedApiClient: RuntimeApiClient | undefined;
|
||||||
|
let versionCounter = 0;
|
||||||
|
|
||||||
|
function notifyListeners(resourceKey: string, version: string) {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(resourceKey, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchResource(
|
||||||
|
resource: ResourceDefinition<unknown>,
|
||||||
|
apiClient: RuntimeApiClient,
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await resource.fetch(apiClient);
|
||||||
|
const json = JSON.stringify(data);
|
||||||
|
const previousJson = serializedCache.get(resource.key);
|
||||||
|
|
||||||
|
if (previousJson === json) {
|
||||||
|
log.debug("api", "Live store: %s unchanged", resource.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = String(++versionCounter);
|
||||||
|
serializedCache.set(resource.key, json);
|
||||||
|
|
||||||
|
const snapshot: Snapshot<unknown> = {
|
||||||
|
data,
|
||||||
|
version,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
snapshots.set(resource.key, snapshot);
|
||||||
|
log.debug("api", "Live store: %s updated (v%s)", resource.key, version);
|
||||||
|
|
||||||
|
if (previousJson !== undefined) {
|
||||||
|
notifyListeners(resource.key, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePolling(resource: ResourceDefinition<unknown>) {
|
||||||
|
if (intervals.has(resource.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
if (!storedApiClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchResource(resource, storedApiClient);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("api", "Live store: failed to poll %s", resource.key, error);
|
||||||
|
}
|
||||||
|
}, resource.pollInterval);
|
||||||
|
|
||||||
|
intervals.set(resource.key, interval);
|
||||||
|
log.debug(
|
||||||
|
"api",
|
||||||
|
"Live store: started polling %s every %dms",
|
||||||
|
resource.key,
|
||||||
|
resource.pollInterval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findResource(key: string): ResourceDefinition<unknown> | undefined {
|
||||||
|
return resources.find((r) => r.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async get<T>(
|
||||||
|
resource: ResourceDefinition<T>,
|
||||||
|
apiClient: RuntimeApiClient,
|
||||||
|
): Promise<Snapshot<T>> {
|
||||||
|
storedApiClient = apiClient;
|
||||||
|
const def = findResource(resource.key);
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`LiveStore: unknown resource "${resource.key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshots.has(resource.key)) {
|
||||||
|
await fetchResource(def, apiClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePolling(def);
|
||||||
|
return snapshots.get(resource.key) as Snapshot<T>;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
|
||||||
|
storedApiClient = apiClient;
|
||||||
|
const def = findResource(resource.key);
|
||||||
|
if (!def) {
|
||||||
|
throw new Error(`LiveStore: unknown resource "${resource.key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchResource(def, apiClient);
|
||||||
|
},
|
||||||
|
|
||||||
|
getVersions(): Record<string, string> {
|
||||||
|
const versions: Record<string, string> = {};
|
||||||
|
for (const [key, snapshot] of snapshots) {
|
||||||
|
versions[key] = snapshot.version;
|
||||||
|
}
|
||||||
|
return versions;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const interval of intervals.values()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
intervals.clear();
|
||||||
|
snapshots.clear();
|
||||||
|
serializedCache.clear();
|
||||||
|
listeners.clear();
|
||||||
|
storedApiClient = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { loadConfig } from "./config/load";
|
|||||||
import { createDbClient } from "./db/client.server";
|
import { createDbClient } from "./db/client.server";
|
||||||
import { createHeadscaleInterface } from "./headscale/api";
|
import { createHeadscaleInterface } from "./headscale/api";
|
||||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||||
|
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||||
import { createHeadplaneAgent } from "./hp-agent";
|
import { createHeadplaneAgent } from "./hp-agent";
|
||||||
import { createAuthService } from "./web/auth";
|
import { createAuthService } from "./web/auth";
|
||||||
|
|
||||||
@@ -53,8 +54,11 @@ declare module "react-router" {
|
|||||||
interface AppLoadContext extends LoadContext {}
|
interface AppLoadContext extends LoadContext {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||||
|
|
||||||
const appLoadContext = {
|
const appLoadContext = {
|
||||||
config,
|
config,
|
||||||
|
hsLive,
|
||||||
hs: await loadHeadscaleConfig(
|
hs: await loadHeadscaleConfig(
|
||||||
config.headscale.config_path,
|
config.headscale.config_path,
|
||||||
config.headscale.config_strict,
|
config.headscale.config_strict,
|
||||||
|
|||||||
+124
-49
@@ -1,67 +1,142 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from "react-router";
|
||||||
import { useInterval } from 'usehooks-ts';
|
|
||||||
|
|
||||||
const LiveDataPausedContext = createContext({
|
const LiveDataContext = createContext({
|
||||||
paused: false,
|
paused: false,
|
||||||
setPaused: (_: boolean) => {},
|
setPaused: (_: boolean) => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface LiveDataProps {
|
interface LiveDataProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Versions = Record<string, string>;
|
||||||
|
interface ChangedEvent {
|
||||||
|
resource: string;
|
||||||
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveDataProvider({ children }: LiveDataProps) {
|
export function LiveDataProvider({ children }: LiveDataProps) {
|
||||||
const [paused, setPaused] = useState(false);
|
const revalidator = useRevalidator();
|
||||||
const revalidator = useRevalidator();
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
// Document is marked as optional here because it's not available in SSR
|
// This ref is a bit sus but it's needed to ensure the SSE handshake does
|
||||||
// The optional chain means if document is not defined, visible is false
|
// not re-establish on every revalidation. The SSE stream is always stable
|
||||||
const [visible, setVisible] = useState(
|
const revalidatorRef = useRef(revalidator);
|
||||||
() =>
|
revalidatorRef.current = revalidator;
|
||||||
typeof document !== 'undefined' && document.visibilityState === 'visible',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Function to revalidate safely
|
const versionsRef = useRef<Versions>({});
|
||||||
const revalidateIfIdle = () => {
|
const isTabDirtyRef = useRef(false);
|
||||||
if (revalidator.state === 'idle') {
|
|
||||||
revalidator.revalidate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const handleVisibilityChange = () => {
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
setVisible(document.visibilityState === 'visible');
|
const backoffRef = useRef(1000);
|
||||||
if (!paused && document.visibilityState === 'visible') {
|
|
||||||
revalidateIfIdle();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('online', revalidateIfIdle);
|
const revalidateIfIdle = useCallback(() => {
|
||||||
document.addEventListener('focus', revalidateIfIdle);
|
if (revalidatorRef.current.state === "idle") {
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
revalidatorRef.current.revalidate();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return () => {
|
// SSE connection
|
||||||
window.removeEventListener('online', revalidateIfIdle);
|
useEffect(() => {
|
||||||
document.removeEventListener('focus', revalidateIfIdle);
|
if (paused) {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
if (eventSourceRef.current) {
|
||||||
};
|
eventSourceRef.current.close();
|
||||||
}, [paused, revalidator]);
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Poll only when visible and not paused
|
return;
|
||||||
useInterval(revalidateIfIdle, visible && !paused ? 3000 : null);
|
}
|
||||||
|
|
||||||
return (
|
function connect() {
|
||||||
<LiveDataPausedContext.Provider value={{ paused, setPaused }}>
|
const sse = new EventSource(`${__PREFIX__}/events/live`);
|
||||||
{children}
|
eventSourceRef.current = sse;
|
||||||
</LiveDataPausedContext.Provider>
|
|
||||||
);
|
sse.addEventListener("hello", (e) => {
|
||||||
|
backoffRef.current = 1000;
|
||||||
|
try {
|
||||||
|
versionsRef.current = JSON.parse(e.data) as Versions;
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
sse.addEventListener("changed", (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data) as ChangedEvent;
|
||||||
|
const current = versionsRef.current[data.resource];
|
||||||
|
if (current !== undefined && data.version === current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionsRef.current = {
|
||||||
|
...versionsRef.current,
|
||||||
|
[data.resource]: data.version,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
isTabDirtyRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateIfIdle();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
sse.onerror = () => {
|
||||||
|
sse.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
const delay = backoffRef.current;
|
||||||
|
backoffRef.current = Math.min(delay * 2, 30_000);
|
||||||
|
reconnectTimer.current = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectTimer.current) {
|
||||||
|
clearTimeout(reconnectTimer.current);
|
||||||
|
reconnectTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [paused, revalidateIfIdle]);
|
||||||
|
|
||||||
|
// If the tab becomes visible and is marked dirty, revalidate
|
||||||
|
useEffect(() => {
|
||||||
|
const visibilityCallback = () => {
|
||||||
|
if (document.visibilityState === "visible" && isTabDirtyRef.current) {
|
||||||
|
isTabDirtyRef.current = false;
|
||||||
|
revalidateIfIdle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", visibilityCallback);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", visibilityCallback);
|
||||||
|
};
|
||||||
|
}, [revalidateIfIdle]);
|
||||||
|
|
||||||
|
// Force a revalidation when the app comes back online
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("online", revalidateIfIdle);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", revalidateIfIdle);
|
||||||
|
};
|
||||||
|
}, [revalidateIfIdle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LiveDataContext.Provider value={{ paused, setPaused }}>{children}</LiveDataContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLiveData() {
|
export function useLiveData() {
|
||||||
const context = useContext(LiveDataPausedContext);
|
const context = useContext(LiveDataContext);
|
||||||
return {
|
return {
|
||||||
pause: () => context.setPaused(true),
|
pause: () => context.setPaused(true),
|
||||||
resume: () => context.setPaused(false),
|
resume: () => context.setPaused(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,6 @@
|
|||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"ulidx": "2.4.1",
|
"ulidx": "2.4.1",
|
||||||
"undici": "7.22.0",
|
"undici": "7.22.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
|
||||||
"vite": "8.0.0-beta.0",
|
"vite": "8.0.0-beta.0",
|
||||||
"vite-tsconfig-paths": "^6.0.4",
|
"vite-tsconfig-paths": "^6.0.4",
|
||||||
"vitepress": "next",
|
"vitepress": "next",
|
||||||
|
|||||||
Generated
-19
@@ -209,9 +209,6 @@ importers:
|
|||||||
undici:
|
undici:
|
||||||
specifier: 7.22.0
|
specifier: 7.22.0
|
||||||
version: 7.22.0
|
version: 7.22.0
|
||||||
usehooks-ts:
|
|
||||||
specifier: ^3.1.1
|
|
||||||
version: 3.1.1(react@19.2.4)
|
|
||||||
vite:
|
vite:
|
||||||
specifier: 8.0.0-beta.0
|
specifier: 8.0.0-beta.0
|
||||||
version: 8.0.0-beta.0(@types/node@25.3.1)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 8.0.0-beta.0(@types/node@25.3.1)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
@@ -4073,9 +4070,6 @@ packages:
|
|||||||
lodash.camelcase@4.3.0:
|
lodash.camelcase@4.3.0:
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
lodash.debounce@4.0.8:
|
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
@@ -4813,12 +4807,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
usehooks-ts@3.1.1:
|
|
||||||
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
|
|
||||||
engines: {node: '>=16.15.0'}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
|
||||||
|
|
||||||
utf-8-validate@5.0.10:
|
utf-8-validate@5.0.10:
|
||||||
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
|
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
|
||||||
engines: {node: '>=6.14.2'}
|
engines: {node: '>=6.14.2'}
|
||||||
@@ -8923,8 +8911,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.camelcase@4.3.0: {}
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
long@5.3.2: {}
|
long@5.3.2: {}
|
||||||
@@ -9827,11 +9813,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
usehooks-ts@3.1.1(react@19.2.4):
|
|
||||||
dependencies:
|
|
||||||
lodash.debounce: 4.0.8
|
|
||||||
react: 19.2.4
|
|
||||||
|
|
||||||
utf-8-validate@5.0.10:
|
utf-8-validate@5.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
node-gyp-build: 4.8.4
|
node-gyp-build: 4.8.4
|
||||||
|
|||||||
Reference in New Issue
Block a user