feat: dns cache stats in jobs & cache page (and cleanup)
This commit is contained in:
@@ -2967,6 +2967,62 @@ paths:
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
dnsCache:
|
||||
type: object
|
||||
properties:
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 1
|
||||
maxSize:
|
||||
type: number
|
||||
example: 500
|
||||
hits:
|
||||
type: number
|
||||
example: 19
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
failures:
|
||||
type: number
|
||||
example: 0
|
||||
ipv4Fallbacks:
|
||||
type: number
|
||||
example: 0
|
||||
hitRate:
|
||||
type: number
|
||||
example: 0.95
|
||||
entries:
|
||||
type: array
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
addresses:
|
||||
type: object
|
||||
properties:
|
||||
ipv4:
|
||||
type: number
|
||||
example: 1
|
||||
ipv6:
|
||||
type: number
|
||||
example: 1
|
||||
activeAddress:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
family:
|
||||
type: number
|
||||
example: 4
|
||||
age:
|
||||
type: number
|
||||
example: 10
|
||||
ttl:
|
||||
type: number
|
||||
example: 10
|
||||
networkErrors:
|
||||
type: number
|
||||
example: 0
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -61,9 +61,37 @@ export interface CacheItem {
|
||||
};
|
||||
}
|
||||
|
||||
export interface DNSAddresses {
|
||||
ipv4: number;
|
||||
ipv6: number;
|
||||
}
|
||||
|
||||
export interface DNSRecord {
|
||||
addresses: DNSAddresses;
|
||||
activeAddress: string;
|
||||
family: number;
|
||||
age: number;
|
||||
ttl: number;
|
||||
networkErrors: number;
|
||||
}
|
||||
|
||||
export interface DNSStats {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
failures: number;
|
||||
ipv4Fallbacks: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
dnsCache: {
|
||||
entries: Record<string, DNSRecord>;
|
||||
stats: DNSStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -60,23 +60,8 @@ const filteredMainSettings = (
|
||||
return main;
|
||||
};
|
||||
|
||||
settingsRoutes.get('/dnsCache', async (req, res) => {
|
||||
const stats = dnsCache.getStats();
|
||||
const entries = dnsCache.getCacheEntries();
|
||||
|
||||
res.json({
|
||||
stats,
|
||||
entries,
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.get('/main', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const stats = dnsCache.getStats();
|
||||
const entries = dnsCache.getCacheEntries();
|
||||
|
||||
console.log(entries);
|
||||
console.log(stats);
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 400, message: 'User missing from request.' });
|
||||
@@ -771,12 +756,19 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
const stats = dnsCache.getStats();
|
||||
const entries = dnsCache.getCacheEntries();
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
dnsCache: {
|
||||
stats,
|
||||
entries,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,37 @@ import logger from '@server/logger';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import dns from 'node:dns';
|
||||
|
||||
type LookupCallback = (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[] | undefined,
|
||||
family?: number
|
||||
) => void;
|
||||
|
||||
type LookupFunction = {
|
||||
(hostname: string, callback: LookupCallback): void;
|
||||
(
|
||||
hostname: string,
|
||||
options: dns.LookupOptions,
|
||||
callback: LookupCallback
|
||||
): void;
|
||||
(
|
||||
hostname: string,
|
||||
options: dns.LookupOneOptions,
|
||||
callback: LookupCallback
|
||||
): void;
|
||||
(
|
||||
hostname: string,
|
||||
options: dns.LookupAllOptions,
|
||||
callback: LookupCallback
|
||||
): void;
|
||||
(
|
||||
hostname: string,
|
||||
options: dns.LookupOptions | dns.LookupOneOptions | dns.LookupAllOptions,
|
||||
callback: LookupCallback
|
||||
): void;
|
||||
__promisify__: typeof dns.lookup.__promisify__;
|
||||
} & typeof dns.lookup;
|
||||
|
||||
interface DnsCache {
|
||||
addresses: { ipv4: string[]; ipv6: string[] };
|
||||
activeAddress: string;
|
||||
@@ -33,6 +64,8 @@ class DnsCacheManager {
|
||||
private maxRetries: number;
|
||||
private testMode: boolean;
|
||||
private forceIpv4InTest: boolean;
|
||||
private originalDnsLookup: typeof dns.lookup;
|
||||
private originalPromisify: any;
|
||||
|
||||
constructor(
|
||||
maxSize = 500,
|
||||
@@ -41,6 +74,9 @@ class DnsCacheManager {
|
||||
testMode = false,
|
||||
forceIpv4InTest = true
|
||||
) {
|
||||
this.originalDnsLookup = dns.lookup;
|
||||
this.originalPromisify = this.originalDnsLookup.__promisify__;
|
||||
|
||||
this.cache = new LRUCache<string, DnsCache>({
|
||||
max: maxSize,
|
||||
ttl: hardTtlMs,
|
||||
@@ -58,6 +94,160 @@ class DnsCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const wrappedLookup = ((
|
||||
hostname: string,
|
||||
options:
|
||||
| number
|
||||
| dns.LookupOneOptions
|
||||
| dns.LookupOptions
|
||||
| dns.LookupAllOptions,
|
||||
callback: LookupCallback
|
||||
): void => {
|
||||
if (typeof options === 'function') {
|
||||
callback = options;
|
||||
options = {};
|
||||
}
|
||||
|
||||
this.lookup(hostname)
|
||||
.then((result) => {
|
||||
if ((options as dns.LookupOptions).all) {
|
||||
const allAddresses: dns.LookupAddress[] = [];
|
||||
|
||||
result.addresses.ipv4.forEach((addr) => {
|
||||
allAddresses.push({ address: addr, family: 4 });
|
||||
});
|
||||
|
||||
result.addresses.ipv6.forEach((addr) => {
|
||||
allAddresses.push({ address: addr, family: 6 });
|
||||
});
|
||||
|
||||
callback(
|
||||
null,
|
||||
allAddresses.length > 0
|
||||
? allAddresses
|
||||
: [{ address: result.activeAddress, family: result.family }]
|
||||
);
|
||||
} else {
|
||||
callback(null, result.activeAddress, result.family);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
`Cached DNS lookup failed for ${hostname}, falling back to native DNS: ${error.message}`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
this.originalDnsLookup(
|
||||
hostname,
|
||||
options as any,
|
||||
(err, addr, fam) => {
|
||||
if (!err && addr) {
|
||||
const cacheEntry = {
|
||||
addresses: {
|
||||
ipv4: Array.isArray(addr)
|
||||
? addr
|
||||
.filter((a) => !a.address.includes(':'))
|
||||
.map((a) => a.address)
|
||||
: typeof addr === 'string' && !addr.includes(':')
|
||||
? [addr]
|
||||
: [],
|
||||
ipv6: Array.isArray(addr)
|
||||
? addr
|
||||
.filter((a) => a.address.includes(':'))
|
||||
.map((a) => a.address)
|
||||
: typeof addr === 'string' && addr.includes(':')
|
||||
? [addr]
|
||||
: [],
|
||||
},
|
||||
activeAddress: Array.isArray(addr)
|
||||
? addr[0]?.address || ''
|
||||
: addr || '',
|
||||
family: Array.isArray(addr)
|
||||
? addr[0]?.family || 4
|
||||
: fam || 4,
|
||||
timestamp: Date.now(),
|
||||
ttl: 60000,
|
||||
networkErrors: 0,
|
||||
};
|
||||
|
||||
this.updateCache(hostname, cacheEntry).catch(() => {
|
||||
logger.debug(
|
||||
`Failed to update DNS cache for ${hostname}: ${error.message}`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
callback(err, addr, fam);
|
||||
}
|
||||
);
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
logger.error(
|
||||
`Native DNS fallback also failed for ${hostname}: ${fallbackError.message}`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
callback(error, undefined, undefined);
|
||||
});
|
||||
}) as LookupFunction;
|
||||
|
||||
(wrappedLookup as any).__promisify__ = async function (
|
||||
hostname: string,
|
||||
options?:
|
||||
| dns.LookupAllOptions
|
||||
| dns.LookupOneOptions
|
||||
| number
|
||||
| dns.LookupOptions
|
||||
): Promise<dns.LookupAddress[] | { address: string; family: number }> {
|
||||
try {
|
||||
const result = await this.lookup(hostname);
|
||||
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'all' in options &&
|
||||
options.all === true
|
||||
) {
|
||||
const allAddresses: dns.LookupAddress[] = [];
|
||||
|
||||
result.addresses.ipv4.forEach((addr: string) => {
|
||||
allAddresses.push({ address: addr, family: 4 });
|
||||
});
|
||||
|
||||
result.addresses.ipv6.forEach((addr: string) => {
|
||||
allAddresses.push({ address: addr, family: 6 });
|
||||
});
|
||||
|
||||
return allAddresses.length > 0
|
||||
? allAddresses
|
||||
: [{ address: result.activeAddress, family: result.family }];
|
||||
}
|
||||
|
||||
return { address: result.activeAddress, family: result.family };
|
||||
} catch (error) {
|
||||
if (this.originalPromisify) {
|
||||
const nativeResult = await this.originalPromisify(
|
||||
hostname,
|
||||
options as any
|
||||
);
|
||||
return nativeResult;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
dns.lookup = wrappedLookup;
|
||||
}
|
||||
|
||||
async lookup(
|
||||
hostname: string,
|
||||
retryCount = 0,
|
||||
@@ -111,26 +301,12 @@ class DnsCacheManager {
|
||||
}
|
||||
|
||||
this.stats.hits++;
|
||||
logger.debug(`DNS cache hit for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
activeAddress: cached.activeAddress,
|
||||
family: cached.family,
|
||||
age,
|
||||
ttlRemaining,
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Soft expiration. Will use stale entry while refreshing
|
||||
if (age < this.hardTtlMs) {
|
||||
this.stats.hits++;
|
||||
logger.debug(`Using stale DNS cache for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
address: cached.activeAddress,
|
||||
family: cached.family,
|
||||
age,
|
||||
ttlRemaining,
|
||||
});
|
||||
|
||||
// Background refresh
|
||||
this.resolveWithTtl(hostname)
|
||||
@@ -155,17 +331,6 @@ class DnsCacheManager {
|
||||
ttl: result.ttl,
|
||||
networkErrors: 0,
|
||||
});
|
||||
|
||||
logger.debug(`DNS cache refreshed for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
addresses: {
|
||||
ipv4: result.addresses.ipv4.length,
|
||||
ipv6: result.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress,
|
||||
family,
|
||||
ttl: result.ttl,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
@@ -207,17 +372,6 @@ class DnsCacheManager {
|
||||
|
||||
this.cache.set(hostname, dnsCache);
|
||||
|
||||
logger.debug(`DNS cache miss for ${hostname}, cached new result`, {
|
||||
label: 'DNSCache',
|
||||
addresses: {
|
||||
ipv4: result.addresses.ipv4.length,
|
||||
ipv6: result.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress,
|
||||
family,
|
||||
ttl: result.ttl,
|
||||
});
|
||||
|
||||
return dnsCache;
|
||||
} catch (error) {
|
||||
this.stats.failures++;
|
||||
@@ -386,10 +540,6 @@ class DnsCacheManager {
|
||||
|
||||
const ttlMs = minTtl * 1000;
|
||||
|
||||
logger.debug(
|
||||
`Resolved ${hostname} with TTL: ${minTtl}s, found IPv4: ${addresses.ipv4.length}, IPv6: ${addresses.ipv6.length}`
|
||||
);
|
||||
|
||||
return { addresses, ttl: ttlMs };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, {
|
||||
|
||||
@@ -54,6 +54,15 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
cachekeys: 'Total Keys',
|
||||
cacheksize: 'Key Size',
|
||||
cachevsize: 'Value Size',
|
||||
dnsCache: 'DNS Cache',
|
||||
dnsCacheDescription:
|
||||
'Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.',
|
||||
dnscachename: 'Hostname',
|
||||
dnscacheactiveaddress: 'Active Address',
|
||||
dnscachehits: 'Hits',
|
||||
dnscachemisses: 'Misses',
|
||||
dnscacheage: 'Age',
|
||||
dnscachenetworkerrors: 'Network Errors',
|
||||
flushcache: 'Flush Cache',
|
||||
unknownJob: 'Unknown Job',
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
@@ -173,6 +182,7 @@ const SettingsJobs = () => {
|
||||
refreshInterval: 10000,
|
||||
}
|
||||
);
|
||||
console.log(cacheData);
|
||||
|
||||
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
|
||||
isOpen: false,
|
||||
@@ -567,6 +577,59 @@ const SettingsJobs = () => {
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="heading">{intl.formatMessage(messages.dnsCache)}</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.dnsCacheDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table.TH>{intl.formatMessage(messages.dnscachename)}</Table.TH>
|
||||
<Table.TH>
|
||||
{intl.formatMessage(messages.dnscacheactiveaddress)}
|
||||
</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.dnscachehits)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.dnscachemisses)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.dnscacheage)}</Table.TH>
|
||||
<Table.TH>
|
||||
{intl.formatMessage(messages.dnscachenetworkerrors)}
|
||||
</Table.TH>
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{Object.entries(cacheData?.dnsCache.entries || {}).map(
|
||||
([hostname, data]) => (
|
||||
<tr key={`cache-list-${hostname}`}>
|
||||
<Table.TD>{hostname}</Table.TD>
|
||||
<Table.TD>{data.activeAddress}</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(cacheData?.dnsCache.stats.hits ?? 0)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(cacheData?.dnsCache.stats.misses ?? 0)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(Math.floor(data.age / 1000))}s
|
||||
</Table.TD>
|
||||
<Table.TD>{intl.formatNumber(data.networkErrors)}</Table.TD>
|
||||
{/* <Table.TD alignText="right">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() => flushCache(cache)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.flushcache)}</span>
|
||||
</Button>
|
||||
</Table.TD> */}
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="break-words">
|
||||
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
|
||||
<p className="description">
|
||||
|
||||
Reference in New Issue
Block a user