feat: integrate hostinfo into ui
This commit is contained in:
parent
7d4da73141
commit
e33504016b
@ -4,19 +4,21 @@ import { useMemo } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { Machine, Route, User, HostInfo } from '~/types';
|
||||
import { cn } from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly users: User[];
|
||||
readonly magic?: string;
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
users: User[];
|
||||
magic?: string;
|
||||
stats?: HostInfo;
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
export default function MachineRow({ machine, routes, magic, users, stats }: Props) {
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
@ -151,6 +153,22 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{stats !== undefined ? (
|
||||
<>
|
||||
<p className="font-semibold leading-snug">
|
||||
{hinfo.getTSVersion(stats)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
{hinfo.getOSInfo(stats)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Unknown
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@ -12,12 +12,13 @@ import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
|
||||
|
||||
import { menuAction } from './action';
|
||||
import MachineRow from './components/machine';
|
||||
import NewMachine from './dialogs/new';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
export async function loader({ request, context: lC }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
@ -25,6 +26,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
]);
|
||||
|
||||
initAgentSocket(lC);
|
||||
|
||||
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
|
||||
const context = await loadContext();
|
||||
let magic: string | undefined;
|
||||
|
||||
@ -44,6 +48,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
stats,
|
||||
server: context.headscaleUrl,
|
||||
publicServer: context.headscalePublicUrl,
|
||||
};
|
||||
@ -107,6 +112,7 @@ export default function Page() {
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-2">Version</th>
|
||||
<th className="pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -125,6 +131,7 @@ export default function Page() {
|
||||
)}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
stats={data.stats?.[machine.nodeKey]}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
206
app/types/HostInfo.ts
Normal file
206
app/types/HostInfo.ts
Normal file
@ -0,0 +1,206 @@
|
||||
// Roughly follows the HostInfo we get from the headplane agent
|
||||
// Should it drift too much we may begin to get errors, but in go its stable
|
||||
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
|
||||
|
||||
export interface HostInfo {
|
||||
/** Version of this code (in version.Long format) */
|
||||
IPNVersion?: string;
|
||||
|
||||
/** Logtail ID of frontend instance */
|
||||
FrontendLogID?: string;
|
||||
|
||||
/** Logtail ID of backend instance */
|
||||
BackendLogID?: string;
|
||||
|
||||
/** Operating system the client runs on (a version.OS value) */
|
||||
OS?: string;
|
||||
|
||||
/**
|
||||
* Version of the OS, if available.
|
||||
*
|
||||
* - Android: "10", "11", "12", etc.
|
||||
* - iOS/macOS: "15.6.1", "12.4.0", etc.
|
||||
* - Windows: "10.0.19044.1889", etc.
|
||||
* - FreeBSD: "12.3-STABLE", etc.
|
||||
* - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
|
||||
* - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
|
||||
*/
|
||||
OSVersion?: string;
|
||||
|
||||
/** Whether the client is running in a container (best-effort detection) */
|
||||
Container?: boolean;
|
||||
|
||||
/** Host environment type as a string */
|
||||
Env?: string;
|
||||
|
||||
/** Distribution name (e.g., "debian", "ubuntu", "nixos") */
|
||||
Distro?: string;
|
||||
|
||||
/** Distribution version (e.g., "20.04") */
|
||||
DistroVersion?: string;
|
||||
|
||||
/** Distribution code name (e.g., "jammy", "bullseye") */
|
||||
DistroCodeName?: string;
|
||||
|
||||
/** Used to disambiguate Tailscale clients that run using tsnet */
|
||||
App?: string;
|
||||
|
||||
/** Whether a desktop was detected on Linux */
|
||||
Desktop?: boolean;
|
||||
|
||||
/** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
|
||||
Package?: string;
|
||||
|
||||
/** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
|
||||
DeviceModel?: string;
|
||||
|
||||
/** macOS/iOS APNs device token for notifications (future support for Android) */
|
||||
PushDeviceToken?: string;
|
||||
|
||||
/** Name of the host the client runs on */
|
||||
Hostname?: string;
|
||||
|
||||
/** Indicates whether the host is blocking incoming connections */
|
||||
ShieldsUp?: boolean;
|
||||
|
||||
/** Indicates this node exists in netmap because it's owned by a shared-to user */
|
||||
ShareeNode?: boolean;
|
||||
|
||||
/** Indicates user has opted out of sending logs and support */
|
||||
NoLogsNoSupport?: boolean;
|
||||
|
||||
/** Indicates the node wants the option to receive ingress connections */
|
||||
WireIngress?: boolean;
|
||||
|
||||
/** Indicates node has opted-in to admin-console-driven remote updates */
|
||||
AllowsUpdate?: boolean;
|
||||
|
||||
/** Current host's machine type (e.g., uname -m) */
|
||||
Machine?: string;
|
||||
|
||||
/** `GOARCH` value of the built binary */
|
||||
GoArch?: string;
|
||||
|
||||
/** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
|
||||
GoArchVar?: string;
|
||||
|
||||
/** Go version the binary was built with */
|
||||
GoVersion?: string;
|
||||
|
||||
/** Set of IP ranges this client can route */
|
||||
RoutableIPs?: string[];
|
||||
|
||||
/** Set of ACL tags this node wants to claim */
|
||||
RequestTags?: string[];
|
||||
|
||||
/** MAC addresses to send Wake-on-LAN packets to wake this node */
|
||||
WoLMACs?: string[];
|
||||
|
||||
/** Services advertised by this machine */
|
||||
Services?: Service[];
|
||||
|
||||
/** Networking information about the node */
|
||||
NetInfo?: NetInfo;
|
||||
|
||||
/** SSH host keys if advertised */
|
||||
sshHostKeys?: string[];
|
||||
|
||||
/** Cloud provider information (if any) */
|
||||
Cloud?: string;
|
||||
|
||||
/** Indicates if the client is running in userspace (netstack) mode */
|
||||
Userspace?: boolean;
|
||||
|
||||
/** Indicates if the client's subnet router is running in userspace (netstack) mode */
|
||||
UserspaceRouter?: boolean;
|
||||
|
||||
/** Indicates if the client is running the app-connector service */
|
||||
AppConnector?: boolean;
|
||||
|
||||
/** Opaque hash of the most recent list of tailnet services (indicates config updates) */
|
||||
ServicesHash?: string;
|
||||
|
||||
/** Geographical location data about the Tailscale host (optional) */
|
||||
Location?: Location;
|
||||
}
|
||||
|
||||
/** Represents a network service advertised by a node */
|
||||
interface Service {
|
||||
/** Protocol type (e.g., "tcp", "udp", "peerapi4") */
|
||||
Proto: string;
|
||||
|
||||
/** Port number */
|
||||
Port: number;
|
||||
|
||||
/** Textual description of the service (usually the process name) */
|
||||
Description?: string;
|
||||
}
|
||||
|
||||
/** Networking information for a Tailscale node */
|
||||
interface NetInfo {
|
||||
/** Indicates if NAT mappings vary based on destination IP */
|
||||
MappingVariesByDestIP?: boolean;
|
||||
|
||||
/** Indicates if the router supports hairpinning */
|
||||
HairPinning?: boolean;
|
||||
|
||||
/** Indicates if the host has IPv6 internet connectivity */
|
||||
WorkingIPv6?: boolean;
|
||||
|
||||
/** Indicates if the OS supports IPv6 */
|
||||
OSHasIPv6?: boolean;
|
||||
|
||||
/** Indicates if the host has UDP internet connectivity */
|
||||
WorkingUDP?: boolean;
|
||||
|
||||
/** Indicates if ICMPv4 works (empty if not checked) */
|
||||
WorkingICMPv4?: boolean;
|
||||
|
||||
/** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
|
||||
HavePortMap?: boolean;
|
||||
|
||||
/** Indicates if UPnP appears present on the LAN (empty if not checked) */
|
||||
UPnP?: boolean;
|
||||
|
||||
/** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
|
||||
PMP?: boolean;
|
||||
|
||||
/** Indicates if PCP appears present on the LAN (empty if not checked) */
|
||||
PCP?: boolean;
|
||||
|
||||
/** Preferred DERP region ID */
|
||||
PreferredDERP?: number;
|
||||
|
||||
/** Current link type ("wired", "wifi", "mobile") */
|
||||
LinkType?: string;
|
||||
|
||||
/** Fastest recent time to reach various DERP STUN servers (seconds) */
|
||||
DERPLatency?: Record<string, number>;
|
||||
|
||||
/** Firewall mode on Linux-specific configurations */
|
||||
FirewallMode?: string;
|
||||
}
|
||||
|
||||
/** Represents the geographical location of a Tailscale host */
|
||||
interface Location {
|
||||
/** Country name (user-friendly, properly capitalized) */
|
||||
Country?: string;
|
||||
|
||||
/** ISO 3166-1 alpha-2 country code (upper case) */
|
||||
CountryCode?: string;
|
||||
|
||||
/** City name (user-friendly, properly capitalized) */
|
||||
City?: string;
|
||||
|
||||
/** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
|
||||
CityCode?: string;
|
||||
|
||||
/** Latitude of the node (in degrees, optional) */
|
||||
Latitude?: number;
|
||||
|
||||
/** Longitude of the node (in degrees, optional) */
|
||||
Longitude?: number;
|
||||
|
||||
/** Priority for exit node selection (0 means no priority, negative not allowed) */
|
||||
Priority?: number;
|
||||
}
|
||||
@ -3,3 +3,4 @@ export * from './Machine';
|
||||
export * from './Route';
|
||||
export * from './User';
|
||||
export * from './PreAuthKey';
|
||||
export * from './HostInfo';
|
||||
|
||||
@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale';
|
||||
import { testOidc } from '~/utils/oidc';
|
||||
import log from '~/utils/log';
|
||||
import { initSessionManager } from '~/utils/sessions.server';
|
||||
import { initAgentCache } from '~/utils/ws-agent';
|
||||
|
||||
export interface HeadplaneContext {
|
||||
debug: boolean;
|
||||
@ -21,6 +22,12 @@ export interface HeadplaneContext {
|
||||
cookieSecret: string;
|
||||
integration: IntegrationFactory | undefined;
|
||||
|
||||
cache: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
defaultTTL: number;
|
||||
}
|
||||
|
||||
config: {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
@ -98,6 +105,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
// Initialize Session Management
|
||||
initSessionManager();
|
||||
|
||||
const cacheEnabled = process.env.AGENT_CACHE_DISABLED !== 'true';
|
||||
const cachePath = process.env.AGENT_CACHE_PATH ?? '/etc/headplane/agent.cache';
|
||||
const cacheTTL = 300 * 1000; // 5 minutes
|
||||
|
||||
// Load agent cache
|
||||
if (cacheEnabled) {
|
||||
log.info('CTXT', 'Initializing Agent Cache');
|
||||
log.debug('CTXT', 'Cache Path: %s', cachePath);
|
||||
log.debug('CTXT', 'Cache TTL: %d', cacheTTL);
|
||||
await initAgentCache(cacheTTL, cachePath);
|
||||
}
|
||||
|
||||
context = {
|
||||
debug,
|
||||
headscaleUrl,
|
||||
@ -105,6 +124,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
cookieSecret,
|
||||
integration: await loadIntegration(),
|
||||
config: contextData,
|
||||
cache: {
|
||||
enabled: cacheEnabled,
|
||||
path: cachePath,
|
||||
defaultTTL: cacheTTL,
|
||||
},
|
||||
oidc: await checkOidc(config),
|
||||
};
|
||||
|
||||
|
||||
36
app/utils/host-info.ts
Normal file
36
app/utils/host-info.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { HostInfo } from '~/utils/types';
|
||||
|
||||
export function getTSVersion(host: HostInfo) {
|
||||
const { IPNVersion } = host;
|
||||
if (!IPNVersion) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// IPNVersion is <Semver>-<something>-<something>
|
||||
return IPNVersion.split('-')[0];
|
||||
}
|
||||
|
||||
export function getOSInfo(host: HostInfo) {
|
||||
const { OS, OSVersion } = host;
|
||||
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
|
||||
const formattedOS = formatOS(OS);
|
||||
|
||||
// Trim in case OSVersion is empty
|
||||
return `${formattedOS} ${OSVersion}`.trim();
|
||||
}
|
||||
|
||||
function formatOS(os?: string) {
|
||||
switch (os) {
|
||||
case 'macOS':
|
||||
case 'iOS':
|
||||
return os;
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case undefined:
|
||||
return 'Unknown';
|
||||
default:
|
||||
return os;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
import * as client from 'openid-client';
|
||||
import {
|
||||
authorizationCodeGrantRequest,
|
||||
calculatePKCECodeChallenge,
|
||||
@ -21,8 +22,130 @@ import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import log from '~/utils/log';
|
||||
|
||||
import type { HeadplaneContext } from './config/headplane';
|
||||
import { z } from 'zod';
|
||||
|
||||
type OidcConfig = NonNullable<HeadplaneContext['oidc']>;
|
||||
const oidcConfigSchema = z.object({
|
||||
issuer: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
tokenEndpointAuthMethod: z
|
||||
.enum(['client_secret_post', 'client_secret_basic'])
|
||||
.default('client_secret_basic'),
|
||||
idTokenSigningAlg: z
|
||||
.enum([
|
||||
'RS256',
|
||||
'RS384',
|
||||
'RS512',
|
||||
'ES256',
|
||||
'ES384',
|
||||
'ES512',
|
||||
'PS256',
|
||||
'PS384',
|
||||
'PS512',
|
||||
])
|
||||
.default('RS256'),
|
||||
idTokenEncryptionAlg: z
|
||||
.enum(['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256'])
|
||||
.default('RSA-OAEP'),
|
||||
idTokenEncryptionEnc: z
|
||||
.enum([
|
||||
'A128CBC-HS256',
|
||||
'A192CBC-HS384',
|
||||
'A256CBC-HS512',
|
||||
'A128GCM',
|
||||
'A192GCM',
|
||||
'A256GCM',
|
||||
])
|
||||
.default('A256GCM'),
|
||||
});
|
||||
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
}
|
||||
|
||||
export type OidcConfig = z.infer<typeof oidcConfigSchema>;
|
||||
|
||||
// We try our best to infer the callback URI of our Headplane instance
|
||||
// By default it is always /<base_path>/oidc/callback
|
||||
export function getRedirectUri(req: Request) {
|
||||
const base = __PREFIX__ ?? '/admin'; // Fallback
|
||||
const url = new URL(`${base}/oidc/callback`, req.url);
|
||||
let host = req.headers.get('Host');
|
||||
if (!host) {
|
||||
host = req.headers.get('X-Forwarded-Host');
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
log.error('OIDC', 'Unable to find a host header');
|
||||
log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
|
||||
throw new Error('Could not determine reverse proxy host');
|
||||
}
|
||||
|
||||
const proto = req.headers.get('X-Forwarded-Proto');
|
||||
if (!proto) {
|
||||
log.warn('OIDC', 'No X-Forwarded-Proto header found');
|
||||
log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
|
||||
}
|
||||
|
||||
url.protocol = proto ?? 'http:';
|
||||
url.host = host;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
||||
const config = await client.discovery(
|
||||
oidc.issuer,
|
||||
oidc.clientId,
|
||||
oidc.clientSecret,
|
||||
);
|
||||
|
||||
let codeVerifier: string, codeChallenge: string;
|
||||
codeVerifier = client.randomPKCECodeVerifier();
|
||||
codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
let params: Record<string, string> = {
|
||||
redirect_uri,
|
||||
scope: 'openid profile email',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
}
|
||||
|
||||
// PKCE is backwards compatible with non-PKCE servers
|
||||
// so if we don't support it, just set our nonce
|
||||
if (!config.serverMetadata().supportsPKCE()) {
|
||||
params.nonce = client.randomNonce();
|
||||
}
|
||||
|
||||
const url = client.buildAuthorizationUrl(config, params);
|
||||
return {
|
||||
url: url.href,
|
||||
codeVerifier,
|
||||
nonce: params.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
interface FlowOptions {
|
||||
redirect_uri: string;
|
||||
codeVerifier: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
||||
const config = await client.discovery(
|
||||
oidc.issuer,
|
||||
oidc.clientId,
|
||||
oidc.clientSecret,
|
||||
);
|
||||
|
||||
let subject: string, accessToken: string;
|
||||
const tokens = await client.authorizationCodeGrant(config, new URL(options.redirect_uri), {
|
||||
pkceCodeVerifier: options.codeVerifier,
|
||||
expectedNonce: options.nonce,
|
||||
idTokenExpected: true
|
||||
})
|
||||
|
||||
console.log(tokens);
|
||||
}
|
||||
|
||||
export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'));
|
||||
@ -35,12 +158,13 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// TODO: Properly validate the method is a valid type
|
||||
const method = oidc.method as ClientAuthenticationMethod;
|
||||
const issuerUrl = new URL(oidc.issuer);
|
||||
const oidcClient = {
|
||||
client_id: oidc.client,
|
||||
token_endpoint_auth_method: method
|
||||
token_endpoint_auth_method: method,
|
||||
} satisfies Client;
|
||||
|
||||
const response = await discoveryRequest(issuerUrl);
|
||||
|
||||
139
app/utils/ws-agent.ts
Normal file
139
app/utils/ws-agent.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// Handlers for the Local Agent on the server side
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout as pSetTimeout } from 'node:timers/promises';
|
||||
import type { LoaderFunctionArgs } from 'react-router';
|
||||
import type { HostInfo } from '~/types';
|
||||
import { WebSocket } from 'ws';
|
||||
import { log } from './log';
|
||||
|
||||
// Essentially a HashMap which invalidates entries after a certain time.
|
||||
// It also is capable of syncing as a compressed file to disk.
|
||||
class TimedCache<K, V> {
|
||||
private _cache = new Map<K, V>();
|
||||
private _timeCache = new Map<K, number>();
|
||||
private defaultTTL: number;
|
||||
private filepath: string;
|
||||
|
||||
constructor(defaultTTL: number, filepath: string) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.filepath = filepath;
|
||||
}
|
||||
|
||||
async set(key: K, value: V, ttl: number = this.defaultTTL) {
|
||||
this._cache.set(key, value);
|
||||
this._timeCache.set(key, Date.now() + ttl);
|
||||
await this.syncToFile();
|
||||
}
|
||||
|
||||
async get(key: K) {
|
||||
const entry = this._cache.get(key);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expires = this._timeCache.get(key);
|
||||
if (!expires || expires < Date.now()) {
|
||||
this._cache.delete(key);
|
||||
this._timeCache.delete(key);
|
||||
await this.syncToFile();
|
||||
return;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async loadFromFile() {
|
||||
try {
|
||||
const data = await readFile(this.filepath, 'utf-8');
|
||||
const cache = JSON.parse(data);
|
||||
for (const { key, value, expires } of cache) {
|
||||
this._cache.set(key, value);
|
||||
this._timeCache.set(key, expires);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e['code'] !== 'ENOENT') {
|
||||
// log.error('CACH', 'Failed to load cache from file', e);
|
||||
return;
|
||||
}
|
||||
|
||||
// log.debug('CACH', 'Cache file not found, creating new cache');
|
||||
}
|
||||
}
|
||||
|
||||
private async syncToFile() {
|
||||
const data = Array.from(this._cache.entries()).map(([key, value]) => {
|
||||
return { key, value, expires: this._timeCache.get(key) }
|
||||
});
|
||||
|
||||
await writeFile(this.filepath, JSON.stringify(data), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
let cache: TimedCache<string, HostInfo> | undefined;
|
||||
export async function initAgentCache(defaultTTL: number, filepath: string) {
|
||||
cache = new TimedCache(defaultTTL, filepath);
|
||||
await pSetTimeout(500);
|
||||
await cache.loadFromFile();
|
||||
}
|
||||
|
||||
let agentSocket: WebSocket | undefined;
|
||||
export function initAgentSocket(context: LoaderFunctionArgs['context']) {
|
||||
const client = context.ws.clients.values().next().value;
|
||||
agentSocket = client;
|
||||
}
|
||||
|
||||
// Check the cache and then attempt the websocket query
|
||||
// If we aren't connected to an agent, then debug log and return the cache
|
||||
export async function queryAgent(nodes: string[]) {
|
||||
if (!cache) {
|
||||
log.error('CACH', 'Cache not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const cached: Record<string, HostInfo> = {};
|
||||
await Promise.all(nodes.map(async node => {
|
||||
const cachedData = await cache.get(node);
|
||||
if (cachedData) {
|
||||
cached[node] = cachedData;
|
||||
}
|
||||
}))
|
||||
|
||||
const uncached = nodes.filter(node => !cached[node]);
|
||||
|
||||
// No need to query the agent if we have all the data cached
|
||||
if (uncached.length === 0) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// We don't have an agent socket, so we can't query the agent
|
||||
// and we just return the cached values available instead
|
||||
if (!agentSocket) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
agentSocket.send(JSON.stringify({ NodeIDs: uncached }));
|
||||
const returnData = await new Promise<Record<string, HostInfo> | void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
agentSocket.removeAllListeners('message');
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
agentSocket.on('message', async (message: string) => {
|
||||
const data = JSON.parse(message.toString());
|
||||
if (Object.keys(data).length === 0) {
|
||||
resolve();
|
||||
}
|
||||
|
||||
agentSocket.removeAllListeners('message');
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
if (returnData) {
|
||||
for await (const [node, info] of Object.entries(returnData)) {
|
||||
await cache.set(node, info);
|
||||
}
|
||||
}
|
||||
|
||||
return returnData
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
// This is a "side-effect" but we want a lifecycle cache map of
|
||||
// peer statuses to prevent unnecessary fetches to the agent.
|
||||
import type { LoaderFunctionArgs } from 'react-router';
|
||||
|
||||
type Context = LoaderFunctionArgs['context'];
|
||||
const cache: { [nodeID: string]: unknown } = {};
|
||||
|
||||
export async function queryWS(context: Context, nodeIDs: string[]) {
|
||||
const ws = context.ws;
|
||||
const firstClient = ws.clients.values().next().value;
|
||||
if (!firstClient) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const cached = nodeIDs.map((nodeID) => {
|
||||
const cached = cache[nodeID];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
});
|
||||
|
||||
// We only need to query the nodes that are not cached
|
||||
const uncached = nodeIDs.filter((nodeID) => !cached.includes(nodeID));
|
||||
if (uncached.length === 0) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
firstClient.send(JSON.stringify({ NodeIDs: uncached }));
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
firstClient.on('message', (message: string) => {
|
||||
const data = JSON.parse(message.toString());
|
||||
if (Object.keys(data).length === 0) {
|
||||
resolve();
|
||||
}
|
||||
|
||||
for (const [nodeID, status] of Object.entries(data)) {
|
||||
cache[nodeID] = status;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user