diff --git a/README.md b/README.md index f8788ef..44deee7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://buymeacoffee.com/wojo_o) -# Inker v0.2.1 +# Inker v0.2.1.1 Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) devices and BYOD e-ink displays. Design screens, create custom widgets, and manage your displays from a modern web interface. @@ -115,7 +115,7 @@ docker compose up -d ## Testing ```bash -cd backend && bun test # 375 tests +cd backend && bun test # 395 tests cd frontend && bun run test # 19 tests ``` diff --git a/backend/package.json b/backend/package.json index c458b09..edf716b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "inker-backend", - "version": "0.2.1", + "version": "0.2.1.1", "description": "Inker Server Backend - E-ink Device Management", "main": "dist/main.js", "scripts": { diff --git a/backend/src/custom-widgets/custom-widgets.controller.ts b/backend/src/custom-widgets/custom-widgets.controller.ts index d17b30c..e907981 100644 --- a/backend/src/custom-widgets/custom-widgets.controller.ts +++ b/backend/src/custom-widgets/custom-widgets.controller.ts @@ -50,9 +50,9 @@ export class CustomWidgetsController { } @Get(':id/preview') - @ApiOperation({ summary: 'Get custom widget with rendered data' }) + @ApiOperation({ summary: 'Get custom widget with rendered data (uses cached data, no external fetch)' }) getWithData(@Param('id', ParseIntPipe) id: number) { - return this.customWidgetsService.getWithData(id); + return this.customWidgetsService.getWithData(id, true); } @Patch(':id') diff --git a/backend/src/custom-widgets/custom-widgets.service.ts b/backend/src/custom-widgets/custom-widgets.service.ts index 30009ec..cd662d4 100644 --- a/backend/src/custom-widgets/custom-widgets.service.ts +++ b/backend/src/custom-widgets/custom-widgets.service.ts @@ -177,7 +177,7 @@ export class CustomWidgetsService { * Get widget with rendered data * Fetches latest data from source and applies template */ - async getWithData(id: number) { + async getWithData(id: number, skipFetch = false) { const customWidget = await this.prisma.customWidget.findUnique({ where: { id }, include: { @@ -192,6 +192,7 @@ export class CustomWidgetsService { // Get cached or fresh data from data source const data = await this.dataSourcesService.getCachedData( customWidget.dataSourceId, + skipFetch, ); // Render the data based on display type diff --git a/backend/src/data-sources/data-sources.service.ts b/backend/src/data-sources/data-sources.service.ts index c2a5f27..718e216 100644 --- a/backend/src/data-sources/data-sources.service.ts +++ b/backend/src/data-sources/data-sources.service.ts @@ -837,8 +837,9 @@ export class DataSourcesService { /** * Get cached data for a data source, refreshing if stale + * @param skipFetch - If true, return cached data without fetching (for previews/editing) */ - async getCachedData(id: number): Promise { + async getCachedData(id: number, skipFetch = false): Promise { const dataSource = await this.prisma.dataSource.findUnique({ where: { id }, }); @@ -847,6 +848,11 @@ export class DataSourcesService { throw new NotFoundException('Data source not found'); } + // When skipFetch is true, return cached data without hitting the external API + if (skipFetch && dataSource.lastData) { + return dataSource.lastData; + } + // Check if data is stale const isStale = !dataSource.lastFetchedAt || diff --git a/backend/src/main.ts b/backend/src/main.ts index 97ec8b4..b28bd25 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -81,7 +81,7 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle('Inker API') .setDescription('API documentation for Inker e-ink device management server') - .setVersion('0.2.1') + .setVersion('0.2.1.1') .addBearerAuth() .addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key') .build(); diff --git a/backend/src/screen-designer/services/screen-renderer.service.ts b/backend/src/screen-designer/services/screen-renderer.service.ts index 9a7942d..68b734c 100644 --- a/backend/src/screen-designer/services/screen-renderer.service.ts +++ b/backend/src/screen-designer/services/screen-renderer.service.ts @@ -61,6 +61,11 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { private readonly GITHUB_CACHE_TTL = 5 * 60 * 1000; // 5 minutes private readonly GITHUB_CACHE_MAX_SIZE = 100; + // Weather API cache to reduce redundant Open-Meteo calls (10 minute TTL) + private weatherCache: Map = new Map(); + private readonly WEATHER_CACHE_TTL = 10 * 60 * 1000; // 10 minutes + private readonly WEATHER_CACHE_MAX_SIZE = 50; + constructor( private prisma: PrismaService, private customWidgetsService: CustomWidgetsService, @@ -806,6 +811,14 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { windSpeed: number; dayName: string; } | null> { + // Check cache first + const cacheKey = `${latitude},${longitude},${forecastDay},${forecastTime}`; + const cached = this.weatherCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.WEATHER_CACHE_TTL) { + this.logger.debug(`Weather data for ${cacheKey} served from cache`); + return cached.data; + } + try { // Build API URL with required parameters const url = new URL('https://api.open-meteo.com/v1/forecast'); @@ -842,60 +855,85 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { if (forecastDay === 0) dayName = 'Today'; else if (forecastDay === 1) dayName = 'Tomorrow'; + let result: { temperature: number; weatherCode: number; humidity: number; windSpeed: number; dayName: string }; + // If current weather (day 0, time current), use current data if (forecastDay === 0 && forecastTime === 'current') { - return { + result = { temperature: Math.round(data.current.temperature_2m), weatherCode: data.current.weather_code, humidity: data.current.relative_humidity_2m, windSpeed: Math.round(data.current.wind_speed_10m), dayName, }; - } - - // Otherwise, find the appropriate hourly data - const hourMap: Record = { - 'current': now.getHours(), - 'morning': 8, - 'noon': 12, - 'afternoon': 15, - 'evening': 19, - 'night': 22, - }; - - const targetHour = hourMap[forecastTime] ?? 12; - - // Build target datetime string (YYYY-MM-DDTHH:00) - const year = targetDate.getFullYear(); - const month = String(targetDate.getMonth() + 1).padStart(2, '0'); - const day = String(targetDate.getDate()).padStart(2, '0'); - const hour = String(targetHour).padStart(2, '0'); - const targetTimeStr = `${year}-${month}-${day}T${hour}:00`; - - // Find the index in hourly data - const hourlyTimes = data.hourly?.time || []; - const index = hourlyTimes.findIndex((t: string) => t === targetTimeStr); - - if (index >= 0 && data.hourly) { - return { - temperature: Math.round(data.hourly.temperature_2m[index]), - weatherCode: data.hourly.weather_code[index], - humidity: data.hourly.relative_humidity_2m[index], - windSpeed: Math.round(data.hourly.wind_speed_10m[index]), - dayName, + } else { + // Otherwise, find the appropriate hourly data + const hourMap: Record = { + 'current': now.getHours(), + 'morning': 8, + 'noon': 12, + 'afternoon': 15, + 'evening': 19, + 'night': 22, }; + + const targetHour = hourMap[forecastTime] ?? 12; + + // Build target datetime string (YYYY-MM-DDTHH:00) + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const hour = String(targetHour).padStart(2, '0'); + const targetTimeStr = `${year}-${month}-${day}T${hour}:00`; + + // Find the index in hourly data + const hourlyTimes = data.hourly?.time || []; + const index = hourlyTimes.findIndex((t: string) => t === targetTimeStr); + + if (index >= 0 && data.hourly) { + result = { + temperature: Math.round(data.hourly.temperature_2m[index]), + weatherCode: data.hourly.weather_code[index], + humidity: data.hourly.relative_humidity_2m[index], + windSpeed: Math.round(data.hourly.wind_speed_10m[index]), + dayName, + }; + } else { + // Fallback to current if hourly not found + result = { + temperature: Math.round(data.current.temperature_2m), + weatherCode: data.current.weather_code, + humidity: data.current.relative_humidity_2m, + windSpeed: Math.round(data.current.wind_speed_10m), + dayName, + }; + } } - // Fallback to current if hourly not found - return { - temperature: Math.round(data.current.temperature_2m), - weatherCode: data.current.weather_code, - humidity: data.current.relative_humidity_2m, - windSpeed: Math.round(data.current.wind_speed_10m), - dayName, - }; + // Store in cache and evict expired entries + const now2 = Date.now(); + this.weatherCache.set(cacheKey, { data: result, timestamp: now2 }); + for (const [key, entry] of this.weatherCache.entries()) { + if (now2 - entry.timestamp > this.WEATHER_CACHE_TTL * 2) { + this.weatherCache.delete(key); + } + } + if (this.weatherCache.size > this.WEATHER_CACHE_MAX_SIZE) { + const sorted = [...this.weatherCache.entries()].sort(([, a], [, b]) => a.timestamp - b.timestamp); + for (const [key] of sorted.slice(0, this.weatherCache.size - this.WEATHER_CACHE_MAX_SIZE)) { + this.weatherCache.delete(key); + } + } + this.logger.debug(`Weather data for ${cacheKey} fetched and cached`); + + return result; } catch (error) { this.logger.warn(`Failed to fetch weather: ${error instanceof Error ? error.message : String(error)}`); + // Return stale cache if available + if (cached) { + this.logger.debug(`Returning stale weather cache for ${cacheKey}`); + return cached.data; + } return null; } } @@ -1772,8 +1810,8 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { } try { - // Fetch the custom widget with its rendered data - const preview = await this.customWidgetsService.getWithData(customWidgetId); + // Fetch the custom widget with cached data (no external API fetch during render) + const preview = await this.customWidgetsService.getWithData(customWidgetId, true); const { widget, renderedContent } = preview; const widgetConfig = widget.config as Record; const fieldType = widgetConfig.fieldType as string | undefined; @@ -3026,7 +3064,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { if (!customWidgetId) return '
No widget ID
'; try { - const result = await this.customWidgetsService.getWithData(customWidgetId); + const result = await this.customWidgetsService.getWithData(customWidgetId, true); const renderedContent = result.renderedContent; const widgetConfig = (result.widget?.config as Record) || {}; diff --git a/frontend/package.json b/frontend/package.json index 9560f27..a76bd55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "inker-frontend", "private": true, - "version": "0.2.1", + "version": "0.2.1.1", "type": "module", "scripts": { "dev": "bunx --bun vite", diff --git a/frontend/src/pages/extensions/Extensions.tsx b/frontend/src/pages/extensions/Extensions.tsx index f1ea58b..6e4829e 100644 --- a/frontend/src/pages/extensions/Extensions.tsx +++ b/frontend/src/pages/extensions/Extensions.tsx @@ -242,10 +242,98 @@ function ConfirmDeleteModal({ ); } +/** + * Confirmation Modal for API Test Requests + */ +function ConfirmTestModal({ + isOpen, + itemName, + isBulk, + count, + onConfirm, + onCancel, + isTesting, +}: { + isOpen: boolean; + itemName: string; + isBulk?: boolean; + count?: number; + onConfirm: () => void; + onCancel: () => void; + isTesting: boolean; +}) { + if (!isOpen) return null; + + return ( +
+
+
+
+
+ + + +
+
+

Test API Connection

+

+ {isBulk + ? `This will send a request to all ${count} external APIs. This counts against any rate limits or quotas.` + : 'This will send a request to the external API. This counts against any rate limits or quotas.'} +

+
+
+ + {!isBulk && ( +
+

{itemName}

+
+ )} + +
+ + +
+
+
+ ); +} + /** * Data Sources Tab Content */ -const MAX_AUTO_TEST = 5; // Auto-test if 5 or fewer data sources function DataSourcesTab({ dataSources, @@ -271,7 +359,8 @@ function DataSourcesTab({ const [isDeleting, setIsDeleting] = useState(false); const [testingIds, setTestingIds] = useState>(new Set()); const [dotCount, setDotCount] = useState(1); - const [hasAutoTested, setHasAutoTested] = useState(false); + const [testTarget, setTestTarget] = useState(null); + const [showTestAllModal, setShowTestAllModal] = useState(false); // Animated dots for "Waiting" state useEffect(() => { @@ -282,8 +371,25 @@ function DataSourcesTab({ return () => clearInterval(interval); }, [testingIds.size]); - // Wrap handleTestAll in useCallback to avoid dependency issues - const handleTestAllCallback = useCallback(async () => { + const handleTestDataSource = async (id: number) => { + setTestTarget(null); + setTestingIds((prev) => new Set(prev).add(id)); + try { + await dataSourceService.testFetch(id); + } catch { + // Error will be stored in lastError + } finally { + setTestingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + onRefetch(); + } + }; + + const handleTestAll = useCallback(async () => { + setShowTestAllModal(false); const ids = dataSources.map((ds) => ds.id); setTestingIds(new Set(ids)); @@ -305,33 +411,6 @@ function DataSourcesTab({ onRefetch(); }, [dataSources, onRefetch]); - // Auto-test all data sources on mount (if not too many) - useEffect(() => { - if (!hasAutoTested && dataSources.length > 0 && dataSources.length <= MAX_AUTO_TEST) { - setHasAutoTested(true); - handleTestAllCallback(); - } - }, [dataSources, hasAutoTested, handleTestAllCallback]); - - const handleTestDataSource = async (id: number) => { - setTestingIds((prev) => new Set(prev).add(id)); - try { - await dataSourceService.testFetch(id); - } catch { - // Error will be stored in lastError - } finally { - setTestingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - onRefetch(); - } - }; - - // handleTestAll now uses the memoized callback - const handleTestAll = handleTestAllCallback; - const getWaitingDots = () => '.'.repeat(dotCount); const handleDelete = async () => { @@ -393,32 +472,25 @@ function DataSourcesTab({ return ( <> - {/* Test All button when too many data sources */} - {dataSources.length > MAX_AUTO_TEST && ( -
-

- {testingIds.size > 0 - ? `Testing ${testingIds.size} of ${dataSources.length} data sources...` - : `${dataSources.length} data sources found. Click to test all APIs.`} -

- -
- )} + {/* Test All button */} +
+ +
@@ -466,7 +538,7 @@ function DataSourcesTab({