v0.2.1.1 — API quota protection, caching improvements

This commit is contained in:
wojo
2026-03-15 20:15:52 +00:00
parent ee77c41fe4
commit b46b12dfe5
9 changed files with 246 additions and 111 deletions
+2 -2
View File
@@ -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
```
+1 -1
View File
@@ -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": {
@@ -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')
@@ -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
@@ -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<unknown> {
async getCachedData(id: number, skipFetch = false): Promise<unknown> {
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 ||
+1 -1
View File
@@ -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();
@@ -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<string, { data: { temperature: number; weatherCode: number; humidity: number; windSpeed: number; dayName: string }; timestamp: number }> = 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<string, number> = {
'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<string, number> = {
'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<string, any>;
const fieldType = widgetConfig.fieldType as string | undefined;
@@ -3026,7 +3064,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
if (!customWidgetId) return '<div style="color: #999;">No widget ID</div>';
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<string, any>) || {};
+1 -1
View File
@@ -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",
+148 -58
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-bg-overlay"
onClick={onCancel}
/>
<div className="relative bg-bg-card rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0" style={{ backgroundColor: '#FEF3C7' }}>
<svg className="w-6 h-6" style={{ color: '#D97706' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-text-primary">Test API Connection</h3>
<p className="text-sm text-text-muted">
{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.'}
</p>
</div>
</div>
{!isBulk && (
<div className="bg-bg-muted rounded-lg p-3 mb-6">
<p className="text-sm font-medium text-text-primary truncate">{itemName}</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={isTesting}
className="flex-1 px-4 py-2 text-text-secondary bg-bg-muted rounded-lg hover:bg-border-light transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={isTesting}
className="flex-1 px-4 py-2 text-text-inverse rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
style={{ backgroundColor: '#F59E0B' }}
onMouseEnter={(e) => {
if (!e.currentTarget.disabled) e.currentTarget.style.backgroundColor = '#D97706';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#F59E0B';
}}
>
{isTesting ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Testing...
</>
) : (
'Send Request'
)}
</button>
</div>
</div>
</div>
);
}
/**
* 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<Set<number>>(new Set());
const [dotCount, setDotCount] = useState(1);
const [hasAutoTested, setHasAutoTested] = useState(false);
const [testTarget, setTestTarget] = useState<DataSource | null>(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 && (
<div className="mb-4 flex items-center justify-between bg-status-warning-bg border border-status-warning-border rounded-lg px-4 py-3">
<p className="text-sm text-status-warning-text">
{testingIds.size > 0
? `Testing ${testingIds.size} of ${dataSources.length} data sources...`
: `${dataSources.length} data sources found. Click to test all APIs.`}
</p>
<button
onClick={handleTestAll}
disabled={testingIds.size > 0}
className="px-4 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#F59E0B', color: '#FFFFFF' }}
onMouseEnter={(e) => {
if (!e.currentTarget.disabled) {
e.currentTarget.style.backgroundColor = '#D97706';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#F59E0B';
}}
>
{testingIds.size > 0 ? `Testing${getWaitingDots()}` : 'Test All'}
</button>
</div>
)}
{/* Test All button */}
<div className="mb-4 flex items-center justify-end">
<button
onClick={() => setShowTestAllModal(true)}
disabled={testingIds.size > 0}
className="px-4 py-1.5 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#F59E0B', color: '#FFFFFF' }}
onMouseEnter={(e) => {
if (!e.currentTarget.disabled) {
e.currentTarget.style.backgroundColor = '#D97706';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#F59E0B';
}}
>
{testingIds.size > 0 ? `Testing${getWaitingDots()}` : 'Test All'}
</button>
</div>
<div className="bg-bg-card rounded-xl shadow-sm border border-border-light overflow-hidden">
<table className="min-w-full divide-y divide-border-light">
@@ -466,7 +538,7 @@ function DataSourcesTab({
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleTestDataSource(ds.id)}
onClick={() => setTestTarget(ds)}
disabled={testingIds.has(ds.id)}
className="px-2 py-1 text-xs font-medium rounded-full transition-colors min-w-[80px]"
style={
@@ -551,6 +623,24 @@ function DataSourcesTab({
onCancel={() => setDeleteTarget(null)}
isDeleting={isDeleting}
/>
<ConfirmTestModal
isOpen={!!testTarget}
itemName={testTarget?.name || ''}
onConfirm={() => testTarget && handleTestDataSource(testTarget.id)}
onCancel={() => setTestTarget(null)}
isTesting={!!testTarget && testingIds.has(testTarget.id)}
/>
<ConfirmTestModal
isOpen={showTestAllModal}
itemName=""
isBulk
count={dataSources.length}
onConfirm={handleTestAll}
onCancel={() => setShowTestAllModal(false)}
isTesting={testingIds.size > 0}
/>
</>
);
}