mirror of
https://github.com/usetrmnl/inker.git
synced 2026-04-29 13:45:07 -07:00
v0.2.1.1 — API quota protection, caching improvements
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[](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,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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "inker-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user