Compare commits
25 Commits
develop
...
feat-dns-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3feaba230 | ||
|
|
e90134e645 | ||
|
|
27b22531c1 | ||
|
|
85cf40ad10 | ||
|
|
2f21551204 | ||
|
|
e80f17ddcf | ||
|
|
00262f70db | ||
|
|
4a9cc6ff06 | ||
|
|
dfdfe38726 | ||
|
|
b0fa682bd8 | ||
|
|
f92c0b89e1 | ||
|
|
08282aac6f | ||
|
|
2bc2a4cf3d | ||
|
|
b0eedb16c2 | ||
|
|
f26cd7e9be | ||
|
|
82dde0a246 | ||
|
|
22cea5f23d | ||
|
|
cdb926e325 | ||
|
|
905c52cbdc | ||
|
|
ec028e919f | ||
|
|
3eb63c28cf | ||
|
|
0782e671ab | ||
|
|
2c3a98265b | ||
|
|
45989dd2ab | ||
|
|
e324ae4450 |
@@ -4,6 +4,7 @@ dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
|
||||
@@ -21,5 +21,11 @@ module.exports = {
|
||||
rangeEnd: 0, // default: Infinity
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'cypress/config/settings.cypress.json',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Jellyseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
@@ -187,5 +186,22 @@
|
||||
"image-cache-cleanup": {
|
||||
"schedule": "0 0 5 * * *"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"csrfProtection": false,
|
||||
"trustProxy": false,
|
||||
"forceIpv4First": false,
|
||||
"dnsServers": "",
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"hostname": "",
|
||||
"port": 8080,
|
||||
"useSsl": false,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"bypassFilter": "",
|
||||
"bypassLocalAddresses": true
|
||||
},
|
||||
"dnsCache": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2914,6 +2914,68 @@ 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
|
||||
hits:
|
||||
type: number
|
||||
example: 1
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
@@ -2953,6 +3015,21 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed cache'
|
||||
/settings/cache/dns/{dnsEntry}/flush:
|
||||
post:
|
||||
summary: Flush a specific DNS cache entry
|
||||
description: Flushes a specific DNS cache entry
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: dnsEntry
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed dns cache'
|
||||
/settings/logs:
|
||||
get:
|
||||
summary: Returns logs
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"gravatar-url": "3.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"lru-cache": "^11.0.2",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
"node-cache": "5.1.2",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -110,6 +110,9 @@ importers:
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
lru-cache:
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2
|
||||
mime:
|
||||
specifier: '3'
|
||||
version: 3.0.0
|
||||
@@ -6584,6 +6587,10 @@ packages:
|
||||
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
|
||||
engines: {node: 14 || >=16.14}
|
||||
|
||||
lru-cache@11.0.2:
|
||||
resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -17666,6 +17673,8 @@ snapshots:
|
||||
|
||||
lru-cache@10.2.2: {}
|
||||
|
||||
lru-cache@11.0.2: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
@@ -26,6 +26,7 @@ import imageproxy from '@server/routes/imageproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
import { dnsCache } from '@server/utils/dnsCacheManager';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
@@ -39,6 +40,7 @@ import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import YAML from 'yamljs';
|
||||
import dns from 'node:dns';
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
|
||||
|
||||
@@ -73,6 +75,11 @@ app
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings);
|
||||
|
||||
// Add DNS caching
|
||||
if (settings.network.dnsCache) {
|
||||
dnsCache.initialize();
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
|
||||
@@ -61,9 +61,39 @@ 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;
|
||||
hits: number;
|
||||
misses: 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 {
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
dnsCache: boolean;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -555,6 +556,7 @@ class Settings {
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
dnsCache: false,
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { dnsCache } from '@server/utils/dnsCacheManager';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
@@ -755,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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -778,6 +786,20 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ dnsEntry: string }>(
|
||||
'/cache/dns/:dnsEntry/flush',
|
||||
(req, res, next) => {
|
||||
const dnsEntry = req.params.dnsEntry;
|
||||
|
||||
if (dnsCache) {
|
||||
dnsCache.clear(dnsEntry);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
next({ status: 404, message: 'Cache not found.' });
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post(
|
||||
'/initialize',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
|
||||
920
server/utils/dnsCacheManager.ts
Normal file
920
server/utils/dnsCacheManager.ts
Normal file
@@ -0,0 +1,920 @@
|
||||
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;
|
||||
family: number;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
networkErrors?: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
failures: number;
|
||||
ipv4Fallbacks: number;
|
||||
}
|
||||
|
||||
class DnsCacheManager {
|
||||
private cache: LRUCache<string, DnsCache>;
|
||||
private lookupAsync: typeof dns.promises.lookup;
|
||||
private resolver: dns.promises.Resolver;
|
||||
private stats: CacheStats = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
failures: 0,
|
||||
ipv4Fallbacks: 0,
|
||||
};
|
||||
private hardTtlMs: number;
|
||||
private maxRetries: number;
|
||||
private originalDnsLookup: typeof dns.lookup;
|
||||
private originalPromisify: any;
|
||||
|
||||
constructor(maxSize = 500, hardTtlMs = 300000, maxRetries = 3) {
|
||||
this.originalDnsLookup = dns.lookup;
|
||||
this.originalPromisify = this.originalDnsLookup.__promisify__;
|
||||
|
||||
this.cache = new LRUCache<string, DnsCache>({
|
||||
max: maxSize,
|
||||
ttl: hardTtlMs,
|
||||
});
|
||||
this.hardTtlMs = hardTtlMs;
|
||||
this.lookupAsync = dns.promises.lookup;
|
||||
this.resolver = new dns.promises.Resolver();
|
||||
this.maxRetries = maxRetries;
|
||||
}
|
||||
|
||||
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,
|
||||
hits: 0,
|
||||
misses: 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,
|
||||
forceIpv4 = false
|
||||
): Promise<DnsCache> {
|
||||
if (hostname === 'localhost') {
|
||||
return {
|
||||
addresses: {
|
||||
ipv4: ['127.0.0.1'],
|
||||
ipv6: ['::1'],
|
||||
},
|
||||
activeAddress: '127.0.0.1',
|
||||
family: 4,
|
||||
timestamp: Date.now(),
|
||||
ttl: 0,
|
||||
networkErrors: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// force ipv4 if configured
|
||||
const shouldForceIpv4 = forceIpv4;
|
||||
|
||||
const cached = this.cache.get(hostname);
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp;
|
||||
const ttlRemaining = Math.max(0, cached.ttl - age);
|
||||
|
||||
if (ttlRemaining > 0) {
|
||||
if (
|
||||
shouldForceIpv4 &&
|
||||
cached.family === 6 &&
|
||||
cached.addresses.ipv4.length > 0
|
||||
) {
|
||||
const ipv4Address = cached.addresses.ipv4[0];
|
||||
logger.debug(
|
||||
`Switching from IPv6 to IPv4 for ${hostname} in cypress testing`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
oldAddress: cached.activeAddress,
|
||||
newAddress: ipv4Address,
|
||||
}
|
||||
);
|
||||
|
||||
this.stats.ipv4Fallbacks++;
|
||||
return {
|
||||
...cached,
|
||||
activeAddress: ipv4Address,
|
||||
family: 4,
|
||||
};
|
||||
}
|
||||
|
||||
cached.hits++;
|
||||
this.stats.hits++;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Soft expiration. Will use stale entry while refreshing
|
||||
if (age < this.hardTtlMs) {
|
||||
cached.misses++;
|
||||
this.stats.misses++;
|
||||
|
||||
// Background refresh
|
||||
this.resolveWithTtl(hostname)
|
||||
.then((result) => {
|
||||
const preferredFamily = shouldForceIpv4 ? 4 : 6;
|
||||
|
||||
const activeAddress = this.selectActiveAddress(
|
||||
result.addresses,
|
||||
preferredFamily
|
||||
);
|
||||
const family = activeAddress.includes(':') ? 6 : 4;
|
||||
|
||||
const existing = this.cache.get(hostname);
|
||||
this.cache.set(hostname, {
|
||||
addresses: result.addresses,
|
||||
activeAddress,
|
||||
family,
|
||||
timestamp: Date.now(),
|
||||
ttl: result.ttl,
|
||||
networkErrors: 0,
|
||||
hits: existing?.hits ?? 0,
|
||||
misses: (existing?.misses ?? 0) + 1,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
`Failed to refresh DNS for ${hostname}: ${error.message}`
|
||||
);
|
||||
});
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Hard expiration to remove stale entry
|
||||
this.stats.misses++;
|
||||
this.cache.delete(hostname);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.resolveWithTtl(hostname);
|
||||
|
||||
const preferredFamily = shouldForceIpv4 ? 4 : 6;
|
||||
|
||||
const activeAddress = this.selectActiveAddress(
|
||||
result.addresses,
|
||||
preferredFamily
|
||||
);
|
||||
const family = activeAddress.includes(':') ? 6 : 4;
|
||||
|
||||
const existingMisses = this.cache.get(hostname)?.misses ?? 0;
|
||||
|
||||
const dnsCache: DnsCache = {
|
||||
addresses: result.addresses,
|
||||
activeAddress,
|
||||
family,
|
||||
timestamp: Date.now(),
|
||||
ttl: result.ttl,
|
||||
networkErrors: 0,
|
||||
hits: 0,
|
||||
misses: existingMisses + 1,
|
||||
};
|
||||
|
||||
this.cache.set(hostname, dnsCache);
|
||||
|
||||
return dnsCache;
|
||||
} catch (error) {
|
||||
this.stats.failures++;
|
||||
|
||||
if (retryCount < this.maxRetries) {
|
||||
const backoff = Math.min(100 * Math.pow(2, retryCount), 2000);
|
||||
logger.warn(
|
||||
`DNS lookup failed for ${hostname}, retrying (${retryCount + 1}/${
|
||||
this.maxRetries
|
||||
}) after ${backoff}ms`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
error: error.message,
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff));
|
||||
|
||||
// If this is the last retry and was using IPv6 then force IPv4
|
||||
const shouldTryIpv4 = retryCount === this.maxRetries - 1 && !forceIpv4;
|
||||
|
||||
return this.lookup(hostname, retryCount + 1, shouldTryIpv4);
|
||||
}
|
||||
|
||||
// If there is a stale entry, use it as last resort
|
||||
const staleEntry = this.getStaleEntry(hostname);
|
||||
if (staleEntry) {
|
||||
logger.warn(
|
||||
`Using expired DNS cache as fallback for ${hostname} after ${this.maxRetries} failed lookups`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
activeAddress: staleEntry.activeAddress,
|
||||
}
|
||||
);
|
||||
|
||||
// If cypress testing and IPv4 addresses are available, use those instead
|
||||
if (
|
||||
shouldForceIpv4 &&
|
||||
staleEntry.family === 6 &&
|
||||
staleEntry.addresses.ipv4.length > 0
|
||||
) {
|
||||
this.stats.ipv4Fallbacks++;
|
||||
const ipv4Address = staleEntry.addresses.ipv4[0];
|
||||
logger.debug(
|
||||
`Switching expired cache from IPv6 to IPv4 for ${hostname} in test mode`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
oldAddress: staleEntry.activeAddress,
|
||||
newAddress: ipv4Address,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...staleEntry,
|
||||
activeAddress: ipv4Address,
|
||||
family: 4,
|
||||
timestamp: Date.now(),
|
||||
ttl: 60000,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...staleEntry,
|
||||
timestamp: Date.now(),
|
||||
ttl: 60000,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`DNS lookup failed for ${hostname} after ${this.maxRetries} retries: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private selectActiveAddress(
|
||||
addresses: { ipv4: string[]; ipv6: string[] },
|
||||
preferredFamily: number
|
||||
): string {
|
||||
if (preferredFamily === 4) {
|
||||
return addresses.ipv4.length > 0
|
||||
? addresses.ipv4[0]
|
||||
: addresses.ipv6.length > 0
|
||||
? addresses.ipv6[0]
|
||||
: '127.0.0.1';
|
||||
} else {
|
||||
return addresses.ipv6.length > 0
|
||||
? addresses.ipv6[0]
|
||||
: addresses.ipv4.length > 0
|
||||
? addresses.ipv4[0]
|
||||
: '127.0.0.1';
|
||||
}
|
||||
}
|
||||
|
||||
private getStaleEntry(hostname: string): DnsCache | null {
|
||||
const entry = (this.cache as any).store.get(hostname)?.value;
|
||||
if (entry) {
|
||||
if (!entry.addresses && entry.address) {
|
||||
return {
|
||||
addresses: {
|
||||
ipv4: entry.family === 4 ? [entry.address] : [],
|
||||
ipv6: entry.family === 6 ? [entry.address] : [],
|
||||
},
|
||||
activeAddress: entry.address,
|
||||
family: entry.family,
|
||||
timestamp: entry.timestamp,
|
||||
ttl: entry.ttl,
|
||||
networkErrors: 0,
|
||||
hits: entry.hits,
|
||||
misses: entry.misses,
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveWithTtl(
|
||||
hostname: string
|
||||
): Promise<{ addresses: { ipv4: string[]; ipv6: string[] }; ttl: number }> {
|
||||
if (
|
||||
!this.resolver ||
|
||||
typeof this.resolver.resolve4 !== 'function' ||
|
||||
typeof this.resolver.resolve6 !== 'function'
|
||||
) {
|
||||
throw new Error('Resolver is not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const [ipv4Records, ipv6Records] = await Promise.allSettled([
|
||||
this.resolver.resolve4(hostname, { ttl: true }),
|
||||
this.resolver.resolve6(hostname, { ttl: true }),
|
||||
]);
|
||||
|
||||
const addresses = {
|
||||
ipv4: [] as string[],
|
||||
ipv6: [] as string[],
|
||||
};
|
||||
|
||||
let minTtl = 300;
|
||||
|
||||
if (ipv4Records.status === 'fulfilled' && ipv4Records.value.length > 0) {
|
||||
addresses.ipv4 = ipv4Records.value.map((record) => record.address);
|
||||
|
||||
// Find minimum TTL from IPv4 records
|
||||
const ipv4MinTtl = Math.min(
|
||||
...ipv4Records.value.map((r) => r.ttl || 300)
|
||||
);
|
||||
if (ipv4MinTtl > 0 && ipv4MinTtl < minTtl) {
|
||||
minTtl = ipv4MinTtl;
|
||||
}
|
||||
}
|
||||
|
||||
if (ipv6Records.status === 'fulfilled' && ipv6Records.value.length > 0) {
|
||||
addresses.ipv6 = ipv6Records.value.map((record) => record.address);
|
||||
|
||||
// Find minimum TTL from IPv6 records
|
||||
const ipv6MinTtl = Math.min(
|
||||
...ipv6Records.value.map((r) => r.ttl || 300)
|
||||
);
|
||||
if (ipv6MinTtl > 0 && ipv6MinTtl < minTtl) {
|
||||
minTtl = ipv6MinTtl;
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.ipv4.length === 0 && addresses.ipv6.length === 0) {
|
||||
throw new Error(`No DNS records found for ${hostname}`);
|
||||
}
|
||||
|
||||
const ttlMs = minTtl * 1000;
|
||||
|
||||
return { addresses, ttl: ttlMs };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, {
|
||||
label: 'DNSCache',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cache with an externally provided entry
|
||||
* Used for updating cache from fallback DNS lookups
|
||||
*/
|
||||
async updateCache(hostname: string, entry: DnsCache): Promise<void> {
|
||||
if (!entry || !entry.activeAddress || !entry.addresses) {
|
||||
throw new Error('Invalid cache entry provided');
|
||||
}
|
||||
|
||||
const validatedEntry: DnsCache = {
|
||||
addresses: {
|
||||
ipv4: Array.isArray(entry.addresses.ipv4) ? entry.addresses.ipv4 : [],
|
||||
ipv6: Array.isArray(entry.addresses.ipv6) ? entry.addresses.ipv6 : [],
|
||||
},
|
||||
activeAddress: entry.activeAddress,
|
||||
family: entry.family || (entry.activeAddress.includes(':') ? 6 : 4),
|
||||
timestamp: entry.timestamp || Date.now(),
|
||||
ttl: entry.ttl || 60000,
|
||||
networkErrors: entry.networkErrors || 0,
|
||||
hits: entry.hits || 0,
|
||||
misses: entry.misses || 0,
|
||||
};
|
||||
|
||||
if (
|
||||
validatedEntry.addresses.ipv4.length === 0 &&
|
||||
validatedEntry.addresses.ipv6.length === 0
|
||||
) {
|
||||
if (validatedEntry.activeAddress.includes(':')) {
|
||||
validatedEntry.addresses.ipv6.push(validatedEntry.activeAddress);
|
||||
} else {
|
||||
validatedEntry.addresses.ipv4.push(validatedEntry.activeAddress);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = this.cache.get(hostname);
|
||||
if (existing) {
|
||||
const mergedEntry: DnsCache = {
|
||||
addresses: {
|
||||
ipv4: [
|
||||
...new Set([
|
||||
...existing.addresses.ipv4,
|
||||
...validatedEntry.addresses.ipv4,
|
||||
]),
|
||||
],
|
||||
ipv6: [
|
||||
...new Set([
|
||||
...existing.addresses.ipv6,
|
||||
...validatedEntry.addresses.ipv6,
|
||||
]),
|
||||
],
|
||||
},
|
||||
activeAddress: validatedEntry.activeAddress,
|
||||
family: validatedEntry.family,
|
||||
timestamp: validatedEntry.timestamp,
|
||||
ttl: validatedEntry.ttl,
|
||||
networkErrors: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
};
|
||||
|
||||
this.cache.set(hostname, mergedEntry);
|
||||
logger.debug(`Updated DNS cache for ${hostname} with merged entry`, {
|
||||
label: 'DNSCache',
|
||||
addresses: {
|
||||
ipv4: mergedEntry.addresses.ipv4.length,
|
||||
ipv6: mergedEntry.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress: mergedEntry.activeAddress,
|
||||
family: mergedEntry.family,
|
||||
});
|
||||
} else {
|
||||
this.cache.set(hostname, validatedEntry);
|
||||
logger.debug(`Added new DNS cache entry for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
addresses: {
|
||||
ipv4: validatedEntry.addresses.ipv4.length,
|
||||
ipv6: validatedEntry.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress: validatedEntry.activeAddress,
|
||||
family: validatedEntry.family,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback DNS lookup when cache fails
|
||||
* Respects system DNS configuration
|
||||
*/
|
||||
async fallbackLookup(hostname: string): Promise<DnsCache> {
|
||||
logger.warn(`Performing fallback DNS lookup for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
});
|
||||
|
||||
// Try different DNS resolution methods
|
||||
const strategies = [
|
||||
this.tryNodeDefaultLookup.bind(this),
|
||||
this.tryNodePromisesLookup.bind(this),
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
const result = await strategy(hostname);
|
||||
if (
|
||||
result &&
|
||||
(result.addresses.ipv4.length > 0 || result.addresses.ipv6.length > 0)
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.debug(
|
||||
`Fallback strategy failed for ${hostname}: ${error.message}`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
strategy: strategy.name,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw (
|
||||
lastError ||
|
||||
new Error(`All DNS fallback strategies failed for ${hostname}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt lookup using Node's default dns.lookup
|
||||
*/
|
||||
private async tryNodeDefaultLookup(hostname: string): Promise<DnsCache> {
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.lookup(hostname, { all: true }, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addresses || addresses.length === 0) {
|
||||
reject(new Error('No addresses returned'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ipv4Addresses = addresses
|
||||
.filter((a) => a.family === 4)
|
||||
.map((a) => a.address);
|
||||
|
||||
const ipv6Addresses = addresses
|
||||
.filter((a) => a.family === 6)
|
||||
.map((a) => a.address);
|
||||
|
||||
let activeAddress: string;
|
||||
let family: number;
|
||||
|
||||
if (ipv6Addresses.length > 0) {
|
||||
activeAddress = ipv6Addresses[0];
|
||||
family = 6;
|
||||
} else if (ipv4Addresses.length > 0) {
|
||||
activeAddress = ipv4Addresses[0];
|
||||
family = 4;
|
||||
} else {
|
||||
reject(new Error('No valid addresses found'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
addresses: { ipv4: ipv4Addresses, ipv6: ipv6Addresses },
|
||||
activeAddress,
|
||||
family,
|
||||
timestamp: Date.now(),
|
||||
ttl: 60000,
|
||||
networkErrors: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try lookup using Node's dns.promises API directly
|
||||
* This uses a different internal implementation than dns.lookup
|
||||
*/
|
||||
private async tryNodePromisesLookup(hostname: string): Promise<DnsCache> {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
|
||||
const [ipv4Results, ipv6Results] = await Promise.allSettled([
|
||||
resolver.resolve4(hostname).catch(() => []),
|
||||
resolver.resolve6(hostname).catch(() => []),
|
||||
]);
|
||||
|
||||
const ipv4Addresses =
|
||||
ipv4Results.status === 'fulfilled' ? ipv4Results.value : [];
|
||||
const ipv6Addresses =
|
||||
ipv6Results.status === 'fulfilled' ? ipv6Results.value : [];
|
||||
|
||||
if (ipv4Addresses.length === 0 && ipv6Addresses.length === 0) {
|
||||
throw new Error('No addresses resolved');
|
||||
}
|
||||
|
||||
let activeAddress: string;
|
||||
let family: number;
|
||||
|
||||
if (ipv6Addresses.length > 0) {
|
||||
activeAddress = ipv6Addresses[0];
|
||||
family = 6;
|
||||
} else {
|
||||
activeAddress = ipv4Addresses[0];
|
||||
family = 4;
|
||||
}
|
||||
|
||||
return {
|
||||
addresses: { ipv4: ipv4Addresses, ipv6: ipv6Addresses },
|
||||
activeAddress,
|
||||
family,
|
||||
timestamp: Date.now(),
|
||||
ttl: 30000,
|
||||
networkErrors: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
};
|
||||
}
|
||||
|
||||
reportNetworkError(hostname: string) {
|
||||
const entry = this.cache.get(hostname);
|
||||
if (entry) {
|
||||
if (!entry.addresses && (entry as any).address) {
|
||||
const oldEntry = entry as any;
|
||||
entry.addresses = {
|
||||
ipv4: oldEntry.family === 4 ? [oldEntry.address] : [],
|
||||
ipv6: oldEntry.family === 6 ? [oldEntry.address] : [],
|
||||
};
|
||||
entry.activeAddress = oldEntry.address;
|
||||
delete (entry as any).address;
|
||||
}
|
||||
|
||||
entry.networkErrors = (entry.networkErrors || 0) + 1;
|
||||
|
||||
// If there are multiple network errors for this address and alternatives exist, then switch
|
||||
if (entry.networkErrors > 2) {
|
||||
if (entry.family === 6 && entry.addresses.ipv4.length > 0) {
|
||||
logger.info(
|
||||
`Switching ${hostname} from IPv6 to IPv4 after network errors`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
oldAddress: entry.activeAddress,
|
||||
newAddress: entry.addresses.ipv4[0],
|
||||
errors: entry.networkErrors,
|
||||
}
|
||||
);
|
||||
|
||||
entry.activeAddress = entry.addresses.ipv4[0];
|
||||
entry.family = 4;
|
||||
entry.networkErrors = 0;
|
||||
} else if (entry.family === 4 && entry.addresses.ipv4.length > 1) {
|
||||
const currentIndex = entry.addresses.ipv4.indexOf(
|
||||
entry.activeAddress
|
||||
);
|
||||
const nextIndex = (currentIndex + 1) % entry.addresses.ipv4.length;
|
||||
|
||||
logger.info(
|
||||
`Rotating to next IPv4 address for ${hostname} after network errors`,
|
||||
{
|
||||
label: 'DNSCache',
|
||||
oldAddress: entry.activeAddress,
|
||||
newAddress: entry.addresses.ipv4[nextIndex],
|
||||
errors: entry.networkErrors,
|
||||
}
|
||||
);
|
||||
|
||||
entry.activeAddress = entry.addresses.ipv4[nextIndex];
|
||||
entry.networkErrors = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.cache.max,
|
||||
hits: this.stats.hits,
|
||||
misses: this.stats.misses,
|
||||
failures: this.stats.failures,
|
||||
ipv4Fallbacks: this.stats.ipv4Fallbacks,
|
||||
hitRate: this.stats.hits / (this.stats.hits + this.stats.misses || 1),
|
||||
};
|
||||
}
|
||||
|
||||
getCacheEntries() {
|
||||
const entries: Record<
|
||||
string,
|
||||
{
|
||||
addresses: { ipv4: number; ipv6: number };
|
||||
activeAddress: string;
|
||||
family: number;
|
||||
age: number;
|
||||
ttl: number;
|
||||
networkErrors?: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [hostname, data] of this.cache.entries()) {
|
||||
const age = Date.now() - data.timestamp;
|
||||
const ttl = Math.max(0, data.ttl - age);
|
||||
|
||||
entries[hostname] = {
|
||||
addresses: {
|
||||
ipv4: data.addresses.ipv4.length,
|
||||
ipv6: data.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress: data.activeAddress,
|
||||
family: data.family,
|
||||
age,
|
||||
ttl,
|
||||
networkErrors: data.networkErrors,
|
||||
hits: data.hits,
|
||||
misses: data.misses,
|
||||
};
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
getCacheEntry(hostname: string) {
|
||||
const entry = this.cache.get(hostname);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
addresses: {
|
||||
ipv4: entry.addresses.ipv4.length,
|
||||
ipv6: entry.addresses.ipv6.length,
|
||||
},
|
||||
activeAddress: entry.activeAddress,
|
||||
family: entry.family,
|
||||
age: Date.now() - entry.timestamp,
|
||||
ttl: Math.max(0, entry.ttl - (Date.now() - entry.timestamp)),
|
||||
networkErrors: entry.networkErrors,
|
||||
};
|
||||
}
|
||||
|
||||
clearHostname(hostname: string): void {
|
||||
if (!hostname || hostname.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cache.has(hostname)) {
|
||||
this.cache.delete(hostname);
|
||||
logger.debug(`Cleared DNS cache entry for ${hostname}`, {
|
||||
label: 'DNSCache',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear(hostname?: string): void {
|
||||
if (hostname && hostname.length > 0) {
|
||||
this.clearHostname(hostname);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.clear();
|
||||
this.stats.hits = 0;
|
||||
this.stats.misses = 0;
|
||||
this.stats.failures = 0;
|
||||
this.stats.ipv4Fallbacks = 0;
|
||||
logger.debug('DNS cache cleared', { label: 'DNSCache' });
|
||||
}
|
||||
}
|
||||
|
||||
export const dnsCache = new DnsCacheManager();
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import type { JobId } from '@server/lib/settings';
|
||||
import axios from 'axios';
|
||||
import cronstrue from 'cronstrue/i18n';
|
||||
import { formatDuration, intervalToDuration } from 'date-fns';
|
||||
import { Fragment, useReducer, useState } from 'react';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
@@ -55,6 +56,26 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
cacheksize: 'Key Size',
|
||||
cachevsize: 'Value Size',
|
||||
flushcache: 'Flush Cache',
|
||||
dnsCache: 'DNS Cache',
|
||||
dnsCacheDescription:
|
||||
'Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.',
|
||||
dnscacheflushed: '{hostname} dns cache flushed.',
|
||||
dnscachename: 'Hostname',
|
||||
dnscacheactiveaddress: 'Active Address',
|
||||
dnscachehits: 'Hits',
|
||||
dnscachemisses: 'Misses',
|
||||
dnscacheage: 'Age',
|
||||
dnscachenetworkerrors: 'Network Errors',
|
||||
flushdnscache: 'Flush DNS Cache',
|
||||
dnsCacheGlobalStats: 'Global DNS Cache Stats',
|
||||
dnsCacheGlobalStatsDescription:
|
||||
'These stats are aggregated across all DNS cache entries.',
|
||||
size: 'Size',
|
||||
hits: 'Hits',
|
||||
misses: 'Misses',
|
||||
failures: 'Failures',
|
||||
ipv4Fallbacks: 'IPv4 Fallbacks',
|
||||
hitRate: 'Hit Rate',
|
||||
unknownJob: 'Unknown Job',
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
'plex-full-scan': 'Plex Full Library Scan',
|
||||
@@ -242,6 +263,18 @@ const SettingsJobs = () => {
|
||||
cacheRevalidate();
|
||||
};
|
||||
|
||||
const flushDnsCache = async (hostname: string) => {
|
||||
await axios.post(`/api/v1/settings/cache/dns/${hostname}/flush`);
|
||||
addToast(
|
||||
intl.formatMessage(messages.dnscacheflushed, { hostname: hostname }),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
cacheRevalidate();
|
||||
};
|
||||
|
||||
const scheduleJob = async () => {
|
||||
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
|
||||
|
||||
@@ -285,6 +318,18 @@ const SettingsJobs = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatAge = (milliseconds: number): string => {
|
||||
const duration = intervalToDuration({
|
||||
start: 0,
|
||||
end: milliseconds,
|
||||
});
|
||||
|
||||
return formatDuration(duration, {
|
||||
format: ['minutes', 'seconds'],
|
||||
zero: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -567,6 +612,95 @@ 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>
|
||||
<Table.TH></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(data.hits)}</Table.TD>
|
||||
<Table.TD>{intl.formatNumber(data.misses)}</Table.TD>
|
||||
<Table.TD>{formatAge(data.age)}</Table.TD>
|
||||
<Table.TD>{intl.formatNumber(data.networkErrors)}</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() => flushDnsCache(hostname)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.flushdnscache)}</span>
|
||||
</Button>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.dnsCacheGlobalStats)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.dnsCacheGlobalStatsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
{Object.entries(cacheData?.dnsCache.stats || {})
|
||||
.filter(([statName]) => statName !== 'maxSize')
|
||||
.map(([statName]) => (
|
||||
<Table.TH key={`dns-stat-header-${statName}`}>
|
||||
{messages[statName]
|
||||
? intl.formatMessage(messages[statName])
|
||||
: statName}
|
||||
</Table.TH>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
<tr>
|
||||
{Object.entries(cacheData?.dnsCache.stats || {})
|
||||
.filter(([statName]) => statName !== 'maxSize')
|
||||
.map(([statName, statValue]) => (
|
||||
<Table.TD key={`dns-stat-${statName}`}>
|
||||
{statName === 'hitRate'
|
||||
? intl.formatNumber(statValue, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: intl.formatNumber(statValue)}
|
||||
</Table.TD>
|
||||
))}
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="break-words">
|
||||
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
|
||||
<p className="description">
|
||||
|
||||
@@ -42,6 +42,11 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
||||
networkDisclaimer:
|
||||
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
|
||||
docs: 'documentation',
|
||||
dnsCache: 'DNS Cache',
|
||||
dnsCacheTip:
|
||||
'Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls',
|
||||
dnsCacheHoverTip:
|
||||
'Do NOT enable this if you are experiencing issues with DNS lookups',
|
||||
});
|
||||
|
||||
const SettingsNetwork = () => {
|
||||
@@ -86,6 +91,7 @@ const SettingsNetwork = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
csrfProtection: data?.csrfProtection,
|
||||
dnsCache: data?.dnsCache,
|
||||
trustProxy: data?.trustProxy,
|
||||
proxyEnabled: data?.proxy?.enabled,
|
||||
proxyHostname: data?.proxy?.hostname,
|
||||
@@ -103,6 +109,7 @@ const SettingsNetwork = () => {
|
||||
await axios.post('/api/v1/settings/network', {
|
||||
csrfProtection: values.csrfProtection,
|
||||
trustProxy: values.trustProxy,
|
||||
dnsCache: values.dnsCache,
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
hostname: values.proxyHostname,
|
||||
@@ -193,6 +200,33 @@ const SettingsNetwork = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="dnsCache" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.dnsCache)}
|
||||
</span>
|
||||
<SettingsBadge badgeType="advanced" className="mr-2" />
|
||||
<SettingsBadge badgeType="restartRequired" />
|
||||
<SettingsBadge badgeType="experimental" className="mr-2" />
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.dnsCacheTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.dnsCacheHoverTip)}
|
||||
>
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="dnsCache"
|
||||
name="dnsCache"
|
||||
onChange={() => {
|
||||
setFieldValue('dnsCache', !values.dnsCache);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="proxyEnabled" className="checkbox-label">
|
||||
<span className="mr-2">
|
||||
|
||||
@@ -886,6 +886,17 @@
|
||||
"components.Settings.SettingsJobsCache.cachevsize": "Value Size",
|
||||
"components.Settings.SettingsJobsCache.canceljob": "Cancel Job",
|
||||
"components.Settings.SettingsJobsCache.command": "Command",
|
||||
"components.Settings.SettingsJobsCache.dnsCache": "DNS Cache",
|
||||
"components.Settings.SettingsJobsCache.dnsCacheDescription": "Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.",
|
||||
"components.Settings.SettingsJobsCache.dnsCacheGlobalStats": "Global DNS Cache Stats",
|
||||
"components.Settings.SettingsJobsCache.dnsCacheGlobalStatsDescription": "These stats are aggregated across all DNS cache entries.",
|
||||
"components.Settings.SettingsJobsCache.dnscacheactiveaddress": "Active Address",
|
||||
"components.Settings.SettingsJobsCache.dnscacheage": "Age",
|
||||
"components.Settings.SettingsJobsCache.dnscacheflushed": "{hostname} dns cache flushed.",
|
||||
"components.Settings.SettingsJobsCache.dnscachehits": "Hits",
|
||||
"components.Settings.SettingsJobsCache.dnscachemisses": "Misses",
|
||||
"components.Settings.SettingsJobsCache.dnscachename": "Hostname",
|
||||
"components.Settings.SettingsJobsCache.dnscachenetworkerrors": "Network Errors",
|
||||
"components.Settings.SettingsJobsCache.download-sync": "Download Sync",
|
||||
"components.Settings.SettingsJobsCache.download-sync-reset": "Download Sync Reset",
|
||||
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
|
||||
@@ -895,12 +906,17 @@
|
||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
||||
"components.Settings.SettingsJobsCache.failures": "Failures",
|
||||
"components.Settings.SettingsJobsCache.flushcache": "Flush Cache",
|
||||
"components.Settings.SettingsJobsCache.flushdnscache": "Flush DNS Cache",
|
||||
"components.Settings.SettingsJobsCache.hitRate": "Hit Rate",
|
||||
"components.Settings.SettingsJobsCache.hits": "Hits",
|
||||
"components.Settings.SettingsJobsCache.image-cache-cleanup": "Image Cache Cleanup",
|
||||
"components.Settings.SettingsJobsCache.imagecache": "Image Cache",
|
||||
"components.Settings.SettingsJobsCache.imagecacheDescription": "When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.",
|
||||
"components.Settings.SettingsJobsCache.imagecachecount": "Images Cached",
|
||||
"components.Settings.SettingsJobsCache.imagecachesize": "Total Cache Size",
|
||||
"components.Settings.SettingsJobsCache.ipv4Fallbacks": "IPv4 Fallbacks",
|
||||
"components.Settings.SettingsJobsCache.jellyfin-full-scan": "Jellyfin Full Library Scan",
|
||||
"components.Settings.SettingsJobsCache.jellyfin-recently-added-scan": "Jellyfin Recently Added Scan",
|
||||
"components.Settings.SettingsJobsCache.jobScheduleEditFailed": "Something went wrong while saving the job.",
|
||||
@@ -912,6 +928,7 @@
|
||||
"components.Settings.SettingsJobsCache.jobsandcache": "Jobs & Cache",
|
||||
"components.Settings.SettingsJobsCache.jobstarted": "{jobname} started.",
|
||||
"components.Settings.SettingsJobsCache.jobtype": "Type",
|
||||
"components.Settings.SettingsJobsCache.misses": "Misses",
|
||||
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
|
||||
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
|
||||
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
|
||||
@@ -921,6 +938,7 @@
|
||||
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
|
||||
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
|
||||
"components.Settings.SettingsJobsCache.runnow": "Run Now",
|
||||
"components.Settings.SettingsJobsCache.size": "Size",
|
||||
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
|
||||
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
|
||||
"components.Settings.SettingsJobsCache.usersavatars": "Users' Avatars",
|
||||
@@ -981,6 +999,9 @@
|
||||
"components.Settings.SettingsNetwork.csrfProtection": "Enable CSRF Protection",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
|
||||
"components.Settings.SettingsNetwork.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
|
||||
"components.Settings.SettingsNetwork.dnsCache": "DNS Cache",
|
||||
"components.Settings.SettingsNetwork.dnsCacheHoverTip": "Do NOT enable this if you are experiencing issues with DNS lookups",
|
||||
"components.Settings.SettingsNetwork.dnsCacheTip": "Enable caching of DNS lookups to optimize performance and avoid making unnecessary API calls",
|
||||
"components.Settings.SettingsNetwork.docs": "documentation",
|
||||
"components.Settings.SettingsNetwork.network": "Network",
|
||||
"components.Settings.SettingsNetwork.networkDisclaimer": "Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.",
|
||||
@@ -1213,7 +1234,7 @@
|
||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign in to your account",
|
||||
"components.Setup.signin": "Sign In",
|
||||
"components.Setup.signinMessage": "Get started by signing in",
|
||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
|
||||
Reference in New Issue
Block a user