Skip to content

Commit c9eb6f5

Browse files
committed
add performance optomizations on load, add in-memory cache for icons
1 parent 19263db commit c9eb6f5

File tree

17 files changed

+699
-4
lines changed

17 files changed

+699
-4
lines changed

backend/src/routes/icons.route.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { Request, Response, Router } from 'express';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { promisify } from 'util';
5+
6+
export const iconsRoute = Router();
7+
8+
const readFile = promisify(fs.readFile);
9+
10+
// Icon cache interface
11+
interface CachedIcon {
12+
data: string;
13+
mimeType: string;
14+
timestamp: number;
15+
size: number;
16+
}
17+
18+
// In-memory icon cache
19+
class IconCache {
20+
private cache = new Map<string, CachedIcon>();
21+
private readonly MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB max cache size
22+
private currentCacheSize = 0;
23+
24+
set(key: string, data: string, mimeType: string): void {
25+
const size = Buffer.byteLength(data, 'base64');
26+
27+
console.log(`CACHE SET: Caching icon: ${key}, size: ${size} bytes, mimeType: ${mimeType}`);
28+
29+
// Check if adding this would exceed cache size limit
30+
if (this.currentCacheSize + size > this.MAX_CACHE_SIZE) {
31+
console.log('CACHE SET: Cache size limit exceeded, evicting oldest entries');
32+
this.evictOldest();
33+
}
34+
35+
// Remove existing entry if it exists
36+
if (this.cache.has(key)) {
37+
const existing = this.cache.get(key)!;
38+
this.currentCacheSize -= existing.size;
39+
console.log(`CACHE SET: Replaced existing entry for ${key}`);
40+
}
41+
42+
this.cache.set(key, {
43+
data,
44+
mimeType,
45+
timestamp: Date.now(),
46+
size
47+
});
48+
49+
this.currentCacheSize += size;
50+
console.log(`CACHE SET: Successfully cached ${key}. Total size: ${this.currentCacheSize}, Count: ${this.cache.size}`);
51+
}
52+
53+
get(key: string): CachedIcon | null {
54+
const item = this.cache.get(key);
55+
56+
if (!item) {
57+
console.log(`CACHE GET: Cache miss for icon: ${key}`);
58+
return null;
59+
}
60+
61+
console.log(`CACHE GET: Cache hit for icon: ${key}`);
62+
return item;
63+
}
64+
65+
private evictOldest(): void {
66+
// Remove the oldest 25% of cache entries
67+
const entries = Array.from(this.cache.entries());
68+
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
69+
70+
const toRemove = Math.ceil(entries.length * 0.25);
71+
for (let i = 0; i < toRemove && i < entries.length; i++) {
72+
const [key, value] = entries[i];
73+
this.cache.delete(key);
74+
this.currentCacheSize -= value.size;
75+
}
76+
}
77+
78+
clear(): void {
79+
this.cache.clear();
80+
this.currentCacheSize = 0;
81+
}
82+
83+
getStats(): { size: number; count: number; maxSize: number } {
84+
return {
85+
size: this.currentCacheSize,
86+
count: this.cache.size,
87+
maxSize: this.MAX_CACHE_SIZE
88+
};
89+
}
90+
}
91+
92+
const iconCache = new IconCache();
93+
94+
interface BulkIconRequest {
95+
iconPaths: string[];
96+
}
97+
98+
// Bulk load icons endpoint
99+
iconsRoute.post('/bulk', async (req: Request, res: Response) => {
100+
try {
101+
const { iconPaths }: BulkIconRequest = req.body;
102+
103+
if (!iconPaths || !Array.isArray(iconPaths)) {
104+
res.status(400).json({ message: 'iconPaths must be an array' });
105+
return;
106+
}
107+
108+
const icons: { [key: string]: string } = {};
109+
const errors: string[] = [];
110+
111+
// Process all icon requests in parallel
112+
const iconPromises = iconPaths.map(async (iconPath: string) => {
113+
try {
114+
console.log(`Processing icon: ${iconPath}`);
115+
116+
// Check cache first
117+
const cached = iconCache.get(iconPath);
118+
if (cached) {
119+
icons[iconPath] = `data:${cached.mimeType};base64,${cached.data}`;
120+
return;
121+
}
122+
123+
// Sanitize the path
124+
const sanitizedPath = iconPath.replace('./assets/', '');
125+
126+
// Determine the full file path
127+
let fullPath: string;
128+
129+
if (sanitizedPath.includes('app-icons/')) {
130+
// Custom uploaded icon
131+
fullPath = path.join('public', 'uploads', sanitizedPath);
132+
} else {
133+
// Standard asset icon from npm package
134+
fullPath = path.join('node_modules', '@loganmarchione', 'homelab-svg-assets', 'assets', sanitizedPath);
135+
}
136+
137+
console.log(`Looking for icon at: ${fullPath}`);
138+
139+
// Check if file exists
140+
if (!fs.existsSync(fullPath)) {
141+
console.log(`File not found: ${fullPath}`);
142+
errors.push(`Icon not found: ${iconPath}`);
143+
return;
144+
}
145+
146+
console.log(`Reading file: ${fullPath}`);
147+
// Read file and convert to base64
148+
const fileBuffer = await readFile(fullPath);
149+
const ext = path.extname(fullPath).toLowerCase();
150+
151+
// Determine MIME type
152+
let mimeType = 'image/png'; // default
153+
switch (ext) {
154+
case '.jpg':
155+
case '.jpeg':
156+
mimeType = 'image/jpeg';
157+
break;
158+
case '.png':
159+
mimeType = 'image/png';
160+
break;
161+
case '.gif':
162+
mimeType = 'image/gif';
163+
break;
164+
case '.svg':
165+
mimeType = 'image/svg+xml';
166+
break;
167+
case '.webp':
168+
mimeType = 'image/webp';
169+
break;
170+
}
171+
172+
const base64Data = fileBuffer.toString('base64');
173+
174+
console.log(`About to cache icon: ${iconPath}, base64 size: ${base64Data.length}`);
175+
176+
// Cache the icon data - this should ALWAYS happen for found files
177+
iconCache.set(iconPath, base64Data, mimeType);
178+
179+
// Store as data URL for frontend
180+
icons[iconPath] = `data:${mimeType};base64,${base64Data}`;
181+
182+
console.log(`Successfully processed and cached icon: ${iconPath}`);
183+
184+
} catch (error) {
185+
console.error(`Error loading icon ${iconPath}:`, error);
186+
errors.push(`Failed to load icon: ${iconPath}`);
187+
}
188+
}); await Promise.all(iconPromises);
189+
190+
// Return the results
191+
res.json({
192+
icons,
193+
errors: errors.length > 0 ? errors : undefined,
194+
loaded: Object.keys(icons).length,
195+
total: iconPaths.length,
196+
cacheStats: iconCache.getStats()
197+
});
198+
199+
} catch (error) {
200+
console.error('Error in bulk icon loading:', error);
201+
res.status(500).json({
202+
message: 'Failed to load icons in bulk',
203+
error: error instanceof Error ? error.message : 'Unknown error'
204+
});
205+
}
206+
});
207+
208+
// Cache management endpoints
209+
iconsRoute.get('/cache/stats', (req: Request, res: Response) => {
210+
try {
211+
const stats = iconCache.getStats();
212+
res.json({
213+
...stats,
214+
sizeFormatted: `${(stats.size / 1024 / 1024).toFixed(2)} MB`,
215+
maxSizeFormatted: `${(stats.maxSize / 1024 / 1024).toFixed(2)} MB`,
216+
utilizationPercent: ((stats.size / stats.maxSize) * 100).toFixed(2) + '%'
217+
});
218+
} catch (error) {
219+
console.error('Error getting cache stats:', error);
220+
res.status(500).json({
221+
message: 'Failed to get cache statistics',
222+
error: error instanceof Error ? error.message : 'Unknown error'
223+
});
224+
}
225+
});
226+
227+
iconsRoute.delete('/cache', (req: Request, res: Response) => {
228+
try {
229+
iconCache.clear();
230+
res.json({
231+
message: 'Icon cache cleared successfully'
232+
});
233+
} catch (error) {
234+
console.error('Error clearing cache:', error);
235+
res.status(500).json({
236+
message: 'Failed to clear cache',
237+
error: error instanceof Error ? error.message : 'Unknown error'
238+
});
239+
}
240+
});
241+
242+
export default iconsRoute;

backend/src/routes/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { authRoute } from './auth.route';
66
import { configRoute } from './config.route';
77
import { delugeRoute } from './deluge.route';
88
import { healthRoute } from './health.route';
9+
import { iconsRoute } from './icons.route';
910
import { jellyfinRoute } from './jellyfin.route';
1011
import { jellyseerRoute } from './jellyseerr.route';
1112
import { notesRoute } from './notes.route';
@@ -20,6 +21,7 @@ import { timezoneRoute } from './timezone.route';
2021
import { transmissionRoute } from './transmission.route';
2122
import { uploadsRoute } from './uploads.route';
2223
import { weatherRoute } from './weather.route';
24+
import { widgetsRoute } from './widgets.route';
2325
import {
2426
apiLimiter,
2527
authLimiter,
@@ -53,6 +55,10 @@ router.use('/app-shortcut', apiLimiter, appShortcutRoute);
5355
// Uploads management routes
5456
router.use('/uploads', apiLimiter, uploadsRoute);
5557

58+
// Bulk loading routes for performance optimization
59+
router.use('/icons', apiLimiter, iconsRoute);
60+
router.use('/widgets', apiLimiter, widgetsRoute);
61+
5662
// Notes routes
5763
router.use('/notes', apiLimiter, notesRoute);
5864

0 commit comments

Comments
 (0)