mirror of
https://github.com/usetrmnl/inker.git
synced 2026-08-13 23:19:06 -07:00
0.4.0 — Grafana plugin UI, section grid rendering, security fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[](https://buymeacoffee.com/wojo_o)
|
||||
|
||||
# Inker v0.3.3
|
||||
# Inker v0.4.0
|
||||
|
||||
Self-hosted e-ink device management server built for the homelab community. Works with [TRMNL](https://usetrmnl.com/) devices (supports firmware 1.7.8) and any BYOD e-ink display. Design screens, create custom widgets with live data from your local network, and manage your displays from a modern web interface.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inker-backend",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.0",
|
||||
"description": "Inker Server Backend - E-ink Device Management",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -110,6 +110,7 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(this.defaultScreenPath);
|
||||
|
||||
@@ -254,6 +255,7 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outputPath);
|
||||
|
||||
|
||||
@@ -122,52 +122,41 @@ describe('DisplayService', () => {
|
||||
});
|
||||
|
||||
describe('getRefreshRateForScreen (private)', () => {
|
||||
const getRate = (screen: any, deviceRate: number, immediate: boolean) =>
|
||||
(service as any).getRefreshRateForScreen(screen, deviceRate, immediate);
|
||||
|
||||
it('should return 1 when shouldRefreshImmediately is true', () => {
|
||||
expect(getRate({}, 900, true)).toBe(1);
|
||||
});
|
||||
const getRate = (screen: any, deviceRate: number) =>
|
||||
(service as any).getRefreshRateForScreen(screen, deviceRate);
|
||||
|
||||
it('should return device refresh rate for normal screens', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
|
||||
expect(getRate(screen, 900, false)).toBe(900);
|
||||
expect(getRate(screen, 900)).toBe(900);
|
||||
});
|
||||
|
||||
it('should return device refresh rate for countdown widgets (no override)', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'countdown' } }] } };
|
||||
expect(getRate(screen, 900, false)).toBe(900);
|
||||
expect(getRate(screen, 900)).toBe(900);
|
||||
});
|
||||
|
||||
it('should return device refresh rate for date widgets (not time-sensitive)', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'date' } }] } };
|
||||
expect(getRate(screen, 900, false)).toBe(900);
|
||||
expect(getRate(screen, 900)).toBe(900);
|
||||
});
|
||||
|
||||
it('should calculate clock refresh based on seconds until next minute', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'clock' } }] } };
|
||||
const rate = getRate(screen, 900, false);
|
||||
const rate = getRate(screen, 900);
|
||||
expect(rate).toBeGreaterThanOrEqual(4);
|
||||
expect(rate).toBeLessThanOrEqual(63);
|
||||
});
|
||||
|
||||
it('should enforce 10 second floor', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
|
||||
expect(getRate(screen, 5, false)).toBe(10);
|
||||
expect(getRate(screen, 5)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextRefreshTimestamp', () => {
|
||||
it('should return ~1 second from now when immediate', () => {
|
||||
const screen = {};
|
||||
const ts = service.getNextRefreshTimestamp(screen, 900, true);
|
||||
expect(ts).not.toBeNull();
|
||||
expect(ts! - Date.now()).toBeLessThanOrEqual(2000);
|
||||
});
|
||||
|
||||
it('should return device refresh rate ms from now for normal screens', () => {
|
||||
const screen = { screenDesign: { widgets: [{ template: { name: 'text' } }] } };
|
||||
const ts = service.getNextRefreshTimestamp(screen, 900, false);
|
||||
const ts = service.getNextRefreshTimestamp(screen, 900);
|
||||
const diff = ts! - Date.now();
|
||||
// Should be approximately 900 seconds from now
|
||||
expect(diff).toBeGreaterThan(899000);
|
||||
@@ -240,7 +229,7 @@ describe('DisplayService', () => {
|
||||
expect(result.reset_firmware).toBe(false);
|
||||
});
|
||||
|
||||
it('should return refresh_rate 1 when refreshPending is true', async () => {
|
||||
it('should use normal refresh_rate even when refreshPending is true', async () => {
|
||||
mockPrisma.device.findFirst.mockResolvedValue({
|
||||
id: 1, name: 'Test', playlist: null, refreshRate: 900, refreshPending: true,
|
||||
});
|
||||
@@ -248,7 +237,7 @@ describe('DisplayService', () => {
|
||||
mockPrisma.firmware.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getDisplayContent('test-key', false, { battery: 80, wifi: -51 });
|
||||
expect(result.refresh_rate).toBe(1);
|
||||
expect(result.refresh_rate).toBe(900);
|
||||
});
|
||||
|
||||
it('should update device metrics', async () => {
|
||||
|
||||
@@ -199,7 +199,7 @@ export class DisplayService {
|
||||
const firmwareUrl = await this.getFirmwareUpdateUrl(device.firmwareVersion || undefined);
|
||||
|
||||
// Default refresh rate (used for default screens or when no playlist)
|
||||
const defaultRefreshRate = shouldRefreshImmediately ? 1 : device.refreshRate;
|
||||
const defaultRefreshRate = device.refreshRate;
|
||||
|
||||
// If no playlist or no screens in playlist, return the default welcome screen
|
||||
if (!device.playlist || !device.playlist.items || device.playlist.items.length === 0) {
|
||||
@@ -313,14 +313,12 @@ export class DisplayService {
|
||||
const effectiveRefreshRate = this.getRefreshRateForScreen(
|
||||
currentScreen,
|
||||
effectiveDeviceRate,
|
||||
shouldRefreshImmediately,
|
||||
);
|
||||
|
||||
// Calculate the next refresh timestamp for minute-synchronized clock updates
|
||||
const nextRefreshAt = this.getNextRefreshTimestamp(
|
||||
currentScreen,
|
||||
effectiveDeviceRate,
|
||||
shouldRefreshImmediately,
|
||||
);
|
||||
|
||||
// Handle both regular screens and designed screens
|
||||
@@ -396,6 +394,7 @@ export class DisplayService {
|
||||
};
|
||||
} else if (currentScreen.pluginInstance?.plugin) {
|
||||
// Plugin instance - render via plugin engine
|
||||
// Use Date.now() so filename changes on every poll, forcing device to fetch fresh render
|
||||
const pluginInstance = currentScreen.pluginInstance;
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -587,13 +586,7 @@ export class DisplayService {
|
||||
private getRefreshRateForScreen(
|
||||
currentScreen: any,
|
||||
deviceRefreshRate: number,
|
||||
shouldRefreshImmediately: boolean,
|
||||
): number {
|
||||
// If refresh is pending, return 1 second to force immediate update
|
||||
if (shouldRefreshImmediately) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let refreshRate = deviceRefreshRate;
|
||||
|
||||
// For screens with clock widgets, calculate exact seconds until next minute boundary
|
||||
@@ -634,13 +627,7 @@ export class DisplayService {
|
||||
getNextRefreshTimestamp(
|
||||
currentScreen: any,
|
||||
deviceRefreshRate: number,
|
||||
shouldRefreshImmediately: boolean,
|
||||
): number | null {
|
||||
// If refresh is pending, refresh immediately
|
||||
if (shouldRefreshImmediately) {
|
||||
return Date.now() + 1000; // 1 second from now
|
||||
}
|
||||
|
||||
let refreshMs = deviceRefreshRate * 1000;
|
||||
|
||||
// For screens with clock widgets, synchronize to minute boundaries
|
||||
|
||||
@@ -88,6 +88,7 @@ export class SetupScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(this.setupScreenPath);
|
||||
|
||||
|
||||
@@ -14,7 +14,20 @@ export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const secret = config.get<string>('encryption.key') || config.get<string>('admin.pin') || 'inker-default-key';
|
||||
const encryptionKey = config.get<string>('encryption.key');
|
||||
const adminPin = config.get<string>('admin.pin');
|
||||
let secret: string;
|
||||
|
||||
if (encryptionKey) {
|
||||
secret = encryptionKey;
|
||||
} else if (adminPin && adminPin !== '1111') {
|
||||
secret = adminPin;
|
||||
this.logger.warn('ENCRYPTION_KEY not set — falling back to ADMIN_PIN. Set ENCRYPTION_KEY for stronger encryption.');
|
||||
} else {
|
||||
secret = 'inker-default-key';
|
||||
this.logger.warn('ENCRYPTION_KEY not set and ADMIN_PIN is default — plugin secrets use weak encryption. Set ENCRYPTION_KEY env variable.');
|
||||
}
|
||||
|
||||
this.key = scryptSync(secret, SALT, KEY_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,18 @@ describe('CustomWidgetsService', () => {
|
||||
let service: CustomWidgetsService;
|
||||
let mockPrisma: ReturnType<typeof createMockPrisma>;
|
||||
let mockDataSourcesService: { getCachedData: ReturnType<typeof createMock> };
|
||||
let mockEventsService: { notifyScreenDesignUpdate: ReturnType<typeof createMock> };
|
||||
let scriptExecutor: ScriptExecutorService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = createMockPrisma();
|
||||
mockDataSourcesService = { getCachedData: createMock() };
|
||||
mockEventsService = { notifyScreenDesignUpdate: createMock() };
|
||||
scriptExecutor = new ScriptExecutorService();
|
||||
service = new CustomWidgetsService(
|
||||
mockPrisma as any,
|
||||
mockDataSourcesService as any,
|
||||
mockEventsService as any,
|
||||
scriptExecutor,
|
||||
);
|
||||
});
|
||||
@@ -417,12 +420,30 @@ describe('CustomWidgetsService', () => {
|
||||
|
||||
it('should delete and return success message', async () => {
|
||||
mockPrisma.customWidget.findUnique.mockResolvedValue({ id: 1, name: 'W' });
|
||||
mockPrisma.screenWidget.findMany.mockResolvedValue([]);
|
||||
mockPrisma.customWidget.delete.mockResolvedValue({});
|
||||
|
||||
const result = await service.remove(1);
|
||||
expect(result).toEqual({ message: 'Custom widget deleted successfully' });
|
||||
expect(mockPrisma.customWidget.delete.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should remove orphaned screen widgets and notify designs', async () => {
|
||||
mockPrisma.customWidget.findUnique.mockResolvedValue({ id: 1, name: 'W' });
|
||||
mockPrisma.screenWidget.findMany.mockResolvedValue([
|
||||
{ id: 10, screenDesignId: 5 },
|
||||
{ id: 11, screenDesignId: 5 },
|
||||
{ id: 12, screenDesignId: 8 },
|
||||
]);
|
||||
mockPrisma.screenWidget.deleteMany.mockResolvedValue({ count: 3 });
|
||||
mockEventsService.notifyScreenDesignUpdate.mockResolvedValue(0);
|
||||
mockPrisma.customWidget.delete.mockResolvedValue({});
|
||||
|
||||
const result = await service.remove(1);
|
||||
expect(result).toEqual({ message: 'Custom widget deleted successfully' });
|
||||
expect(mockPrisma.screenWidget.deleteMany.calls).toHaveLength(1);
|
||||
expect(mockEventsService.notifyScreenDesignUpdate.calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DataSourcesService } from '../data-sources/data-sources.service';
|
||||
import { EventsService } from '../events/events.service';
|
||||
import { ScriptExecutorService } from './services/script-executor.service';
|
||||
import { CreateCustomWidgetDto } from './dto/create-custom-widget.dto';
|
||||
import { UpdateCustomWidgetDto } from './dto/update-custom-widget.dto';
|
||||
@@ -29,6 +30,7 @@ export class CustomWidgetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private dataSourcesService: DataSourcesService,
|
||||
private eventsService: EventsService,
|
||||
private scriptExecutor: ScriptExecutorService,
|
||||
) {}
|
||||
|
||||
@@ -164,6 +166,32 @@ export class CustomWidgetsService {
|
||||
throw new NotFoundException('Custom widget not found');
|
||||
}
|
||||
|
||||
// Find all screen widget instances that reference this custom widget
|
||||
const orphanedWidgets = await this.prisma.screenWidget.findMany({
|
||||
where: {
|
||||
template: { name: 'custom-widget-base' },
|
||||
config: { path: ['customWidgetId'], equals: id },
|
||||
},
|
||||
select: { id: true, screenDesignId: true },
|
||||
});
|
||||
|
||||
// Delete orphaned screen widget instances
|
||||
if (orphanedWidgets.length > 0) {
|
||||
const widgetIds = orphanedWidgets.map(w => w.id);
|
||||
await this.prisma.screenWidget.deleteMany({
|
||||
where: { id: { in: widgetIds } },
|
||||
});
|
||||
this.logger.log(
|
||||
`Removed ${orphanedWidgets.length} widget instance(s) from screen designs`,
|
||||
);
|
||||
|
||||
// Notify affected screen designs to refresh devices
|
||||
const screenDesignIds = [...new Set(orphanedWidgets.map(w => w.screenDesignId))];
|
||||
for (const designId of screenDesignIds) {
|
||||
await this.eventsService.notifyScreenDesignUpdate(designId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.customWidget.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
+4
-3
@@ -42,12 +42,13 @@ async function bootstrap() {
|
||||
credentials: true,
|
||||
});
|
||||
} else {
|
||||
// Default: allow same-origin requests only (match request host dynamically)
|
||||
// Default: allow same-origin requests only
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no Origin header (same-origin, curl, devices)
|
||||
if (!origin) return callback(null, true);
|
||||
callback(null, origin);
|
||||
// Reject cross-origin requests when CORS_ORIGINS is not configured
|
||||
callback(new Error('CORS not allowed'), false);
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
@@ -81,7 +82,7 @@ async function bootstrap() {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Inker API')
|
||||
.setDescription('API documentation for Inker e-ink device management server')
|
||||
.setVersion('0.3.3')
|
||||
.setVersion('0.4.0')
|
||||
.addBearerAuth()
|
||||
.addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key')
|
||||
.build();
|
||||
|
||||
@@ -51,7 +51,8 @@ export class PlaylistsService {
|
||||
// Parse and categorize screen IDs to avoid N+1 queries
|
||||
const designIds: number[] = [];
|
||||
const regularIds: number[] = [];
|
||||
const screenMap = new Map<string, { type: 'design' | 'regular'; id: number; order: number; duration: number }>();
|
||||
const pluginIds: number[] = [];
|
||||
const screenMap = new Map<string, { type: 'design' | 'regular' | 'plugin'; id: number; order: number; duration: number }>();
|
||||
|
||||
screens.forEach((screenData, i) => {
|
||||
if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')) {
|
||||
@@ -67,6 +68,19 @@ export class PlaylistsService {
|
||||
} else {
|
||||
this.logger.warn(`Invalid screen design ID: ${screenData.screenId}`);
|
||||
}
|
||||
} else if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('plugin-')) {
|
||||
const pluginId = parseInt(screenData.screenId.replace('plugin-', ''), 10);
|
||||
if (!isNaN(pluginId)) {
|
||||
pluginIds.push(pluginId);
|
||||
screenMap.set(screenData.screenId, {
|
||||
type: 'plugin',
|
||||
id: pluginId,
|
||||
order: screenData.order ?? i,
|
||||
duration: screenData.duration ?? 60,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`Invalid plugin instance ID: ${screenData.screenId}`);
|
||||
}
|
||||
} else {
|
||||
const screenId = typeof screenData.screenId === 'string'
|
||||
? parseInt(screenData.screenId, 10)
|
||||
@@ -85,25 +99,30 @@ export class PlaylistsService {
|
||||
}
|
||||
});
|
||||
|
||||
// Batch verify existence of all screens/designs
|
||||
const [existingDesigns, existingScreens] = await Promise.all([
|
||||
// Batch verify existence of all screens/designs/plugins
|
||||
const [existingDesigns, existingScreens, existingPlugins] = await Promise.all([
|
||||
designIds.length > 0
|
||||
? this.prisma.screenDesign.findMany({ where: { id: { in: designIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
regularIds.length > 0
|
||||
? this.prisma.screen.findMany({ where: { id: { in: regularIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
pluginIds.length > 0
|
||||
? this.prisma.pluginInstance.findMany({ where: { id: { in: pluginIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const existingDesignIds = new Set(existingDesigns.map((d) => d.id));
|
||||
const existingScreenIds = new Set(existingScreens.map((s) => s.id));
|
||||
const existingPluginIds = new Set(existingPlugins.map((p) => p.id));
|
||||
|
||||
// Build playlist items in a single transaction
|
||||
const itemsToCreate = screens
|
||||
.map((screenData, i) => {
|
||||
const key = typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')
|
||||
? screenData.screenId
|
||||
: String(typeof screenData.screenId === 'string' ? parseInt(screenData.screenId, 10) : screenData.screenId);
|
||||
const sid = screenData.screenId;
|
||||
const key = typeof sid === 'string' && (sid.startsWith('design-') || sid.startsWith('plugin-'))
|
||||
? sid
|
||||
: String(typeof sid === 'string' ? parseInt(sid, 10) : sid);
|
||||
const mapped = screenMap.get(key);
|
||||
if (!mapped) return null;
|
||||
|
||||
@@ -118,6 +137,17 @@ export class PlaylistsService {
|
||||
order: mapped.order,
|
||||
duration: mapped.duration,
|
||||
};
|
||||
} else if (mapped.type === 'plugin') {
|
||||
if (!existingPluginIds.has(mapped.id)) {
|
||||
this.logger.warn(`Plugin instance not found: ${mapped.id}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
playlistId: playlist.id,
|
||||
pluginInstanceId: mapped.id,
|
||||
order: mapped.order,
|
||||
duration: mapped.duration,
|
||||
};
|
||||
} else {
|
||||
if (!existingScreenIds.has(mapped.id)) {
|
||||
this.logger.warn(`Screen not found: ${mapped.id}`);
|
||||
@@ -206,6 +236,9 @@ export class PlaylistsService {
|
||||
},
|
||||
},
|
||||
screenDesign: true,
|
||||
pluginInstance: {
|
||||
include: { plugin: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
@@ -228,9 +261,24 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
// Transform items to screens array for frontend compatibility
|
||||
const screens = playlist.items.map((item) => {
|
||||
if (item.screenDesign) {
|
||||
// Generate preview URL for designed screens (preview=true skips e-ink processing)
|
||||
const screens = playlist.items.map((item: any) => {
|
||||
if (item.pluginInstance) {
|
||||
const previewUrl = `/api/plugins/instances/${item.pluginInstance.id}/render?mode=preview`;
|
||||
return {
|
||||
id: `plugin-${item.pluginInstance.id}`,
|
||||
screenId: `plugin-${item.pluginInstance.id}`,
|
||||
name: item.pluginInstance.name || item.pluginInstance.plugin?.name || 'Plugin',
|
||||
description: item.pluginInstance.plugin?.description,
|
||||
thumbnailUrl: previewUrl,
|
||||
imageUrl: previewUrl,
|
||||
duration: item.duration,
|
||||
order: item.order,
|
||||
isDesigned: false,
|
||||
isPlugin: true,
|
||||
width: Number(item.pluginInstance.settings?.screen_width) || 800,
|
||||
height: Number(item.pluginInstance.settings?.screen_height) || 480,
|
||||
};
|
||||
} else if (item.screenDesign) {
|
||||
const previewUrl = `/api/device-images/design/${item.screenDesign.id}?preview=true`;
|
||||
return {
|
||||
id: `design-${item.screenDesign.id}`,
|
||||
@@ -324,7 +372,8 @@ export class PlaylistsService {
|
||||
// Parse and categorize screen IDs
|
||||
const designIds: number[] = [];
|
||||
const regularIds: number[] = [];
|
||||
const screenMap = new Map<string, { type: 'design' | 'regular'; id: number; order: number; duration: number }>();
|
||||
const pluginIds: number[] = [];
|
||||
const screenMap = new Map<string, { type: 'design' | 'regular' | 'plugin'; id: number; order: number; duration: number }>();
|
||||
|
||||
screens.forEach((screenData, i) => {
|
||||
if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')) {
|
||||
@@ -340,6 +389,19 @@ export class PlaylistsService {
|
||||
} else {
|
||||
this.logger.warn(`Invalid screen design ID: ${screenData.screenId}`);
|
||||
}
|
||||
} else if (typeof screenData.screenId === 'string' && screenData.screenId.startsWith('plugin-')) {
|
||||
const pluginId = parseInt(screenData.screenId.replace('plugin-', ''), 10);
|
||||
if (!isNaN(pluginId)) {
|
||||
pluginIds.push(pluginId);
|
||||
screenMap.set(screenData.screenId, {
|
||||
type: 'plugin',
|
||||
id: pluginId,
|
||||
order: screenData.order ?? i,
|
||||
duration: screenData.duration ?? 60,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`Invalid plugin instance ID: ${screenData.screenId}`);
|
||||
}
|
||||
} else {
|
||||
const screenId = typeof screenData.screenId === 'string'
|
||||
? parseInt(screenData.screenId, 10)
|
||||
@@ -358,25 +420,30 @@ export class PlaylistsService {
|
||||
}
|
||||
});
|
||||
|
||||
// Batch verify existence of all screens/designs
|
||||
const [existingDesigns, existingScreens] = await Promise.all([
|
||||
// Batch verify existence of all screens/designs/plugins
|
||||
const [existingDesigns, existingScreens, existingPlugins] = await Promise.all([
|
||||
designIds.length > 0
|
||||
? this.prisma.screenDesign.findMany({ where: { id: { in: designIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
regularIds.length > 0
|
||||
? this.prisma.screen.findMany({ where: { id: { in: regularIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
pluginIds.length > 0
|
||||
? this.prisma.pluginInstance.findMany({ where: { id: { in: pluginIds } }, select: { id: true } })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const existingDesignIds = new Set(existingDesigns.map((d) => d.id));
|
||||
const existingScreenIds = new Set(existingScreens.map((s) => s.id));
|
||||
const existingPluginIds = new Set(existingPlugins.map((p) => p.id));
|
||||
|
||||
// Build playlist items in a single batch
|
||||
const itemsToCreate = screens
|
||||
.map((screenData, i) => {
|
||||
const key = typeof screenData.screenId === 'string' && screenData.screenId.startsWith('design-')
|
||||
? screenData.screenId
|
||||
: String(typeof screenData.screenId === 'string' ? parseInt(screenData.screenId, 10) : screenData.screenId);
|
||||
const sid = screenData.screenId;
|
||||
const key = typeof sid === 'string' && (sid.startsWith('design-') || sid.startsWith('plugin-'))
|
||||
? sid
|
||||
: String(typeof sid === 'string' ? parseInt(sid, 10) : sid);
|
||||
const mapped = screenMap.get(key);
|
||||
if (!mapped) return null;
|
||||
|
||||
@@ -391,6 +458,17 @@ export class PlaylistsService {
|
||||
order: mapped.order,
|
||||
duration: mapped.duration,
|
||||
};
|
||||
} else if (mapped.type === 'plugin') {
|
||||
if (!existingPluginIds.has(mapped.id)) {
|
||||
this.logger.warn(`Plugin instance not found: ${mapped.id}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
playlistId: id,
|
||||
pluginInstanceId: mapped.id,
|
||||
order: mapped.order,
|
||||
duration: mapped.duration,
|
||||
};
|
||||
} else {
|
||||
if (!existingScreenIds.has(mapped.id)) {
|
||||
this.logger.warn(`Screen not found: ${mapped.id}`);
|
||||
@@ -419,6 +497,7 @@ export class PlaylistsService {
|
||||
include: {
|
||||
screen: true,
|
||||
screenDesign: true,
|
||||
pluginInstance: { include: { plugin: true } },
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
@@ -432,9 +511,24 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
// Transform items to screens array
|
||||
const transformedScreens = updatedPlaylistWithItems.items.map((item) => {
|
||||
if (item.screenDesign) {
|
||||
// Generate preview URL for designed screens (preview=true skips e-ink processing)
|
||||
const transformedScreens = updatedPlaylistWithItems.items.map((item: any) => {
|
||||
if (item.pluginInstance) {
|
||||
const previewUrl = `/api/plugins/instances/${item.pluginInstance.id}/render?mode=preview`;
|
||||
return {
|
||||
id: `plugin-${item.pluginInstance.id}`,
|
||||
screenId: `plugin-${item.pluginInstance.id}`,
|
||||
name: item.pluginInstance.name || item.pluginInstance.plugin?.name || 'Plugin',
|
||||
description: item.pluginInstance.plugin?.description,
|
||||
thumbnailUrl: previewUrl,
|
||||
imageUrl: previewUrl,
|
||||
duration: item.duration,
|
||||
order: item.order,
|
||||
isDesigned: false,
|
||||
isPlugin: true,
|
||||
width: Number(item.pluginInstance.settings?.screen_width) || 800,
|
||||
height: Number(item.pluginInstance.settings?.screen_height) || 480,
|
||||
};
|
||||
} else if (item.screenDesign) {
|
||||
const previewUrl = `/api/device-images/design/${item.screenDesign.id}?preview=true`;
|
||||
return {
|
||||
id: `design-${item.screenDesign.id}`,
|
||||
|
||||
@@ -239,8 +239,43 @@ ${innerHtml}
|
||||
|
||||
// Apply e-ink processing (dithering + optional inversion)
|
||||
const shouldNegate = mode === 'device';
|
||||
const canvas = sharp(rawPng);
|
||||
return this.screenRenderer.applyEinkProcessing(canvas, width, height, shouldNegate);
|
||||
return this.screenRenderer.applyEinkProcessing(rawPng, width, height, shouldNegate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screenshot an external URL with custom headers (e.g. Grafana panel with auth)
|
||||
*/
|
||||
async renderUrlToPng(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
width: number = 800,
|
||||
height: number = 480,
|
||||
mode: 'device' | 'preview' | 'einkPreview' = 'device',
|
||||
evaluateScript?: string,
|
||||
): Promise<Buffer> {
|
||||
const browser = await this.screenRenderer.getBrowser();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
||||
await page.setExtraHTTPHeaders(headers);
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
if (evaluateScript) {
|
||||
await page.evaluate(evaluateScript);
|
||||
// Wait for layout to settle after DOM changes
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const rawPng = Buffer.from(await page.screenshot({ type: 'png', fullPage: false }));
|
||||
|
||||
if (mode === 'preview') return rawPng;
|
||||
|
||||
const shouldNegate = mode === 'device';
|
||||
return this.screenRenderer.applyEinkProcessing(rawPng, width, height, shouldNegate);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { OAuthService } from './oauth/oauth.service';
|
||||
@@ -584,6 +585,7 @@ export class PluginsController {
|
||||
|
||||
@Post('webhooks/:slug')
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({ summary: 'Receive webhook data for a plugin' })
|
||||
async receiveWebhook(
|
||||
@Param('slug') slug: string,
|
||||
@@ -591,4 +593,81 @@ export class PluginsController {
|
||||
) {
|
||||
return this.pluginsService.handleWebhook(slug, body);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Grafana proxy
|
||||
// ========================
|
||||
|
||||
@Post('grafana/dashboards')
|
||||
@ApiOperation({ summary: 'List Grafana dashboards via parent instance' })
|
||||
async grafanaDashboards(@Body() body: { instanceId: number }) {
|
||||
const conn = await this.pluginsService.getGrafanaConnectionById(body.instanceId);
|
||||
if (!conn.grafana_url || !conn.api_key) throw new NotFoundException('Grafana connection not configured');
|
||||
const baseUrl = conn.grafana_url.replace(/\/+$/, '');
|
||||
const resp = await fetch(`${baseUrl}/api/search?limit=1000`, {
|
||||
headers: { Authorization: `Bearer ${conn.api_key}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) throw new NotFoundException(`Grafana returned ${resp.status}: ${resp.statusText}`);
|
||||
const results = await resp.json();
|
||||
return results
|
||||
.filter((d: any) => d.type === 'dash-db')
|
||||
.map((d: any) => ({ uid: d.uid, title: d.folderTitle ? `${d.folderTitle} / ${d.title}` : d.title, uri: d.uri }));
|
||||
}
|
||||
|
||||
@Post('grafana/panels')
|
||||
@ApiOperation({ summary: 'List panels for a Grafana dashboard' })
|
||||
async grafanaPanels(@Body() body: { instanceId: number; dashboard_uid: string }) {
|
||||
const conn = await this.pluginsService.getGrafanaConnectionById(body.instanceId);
|
||||
if (!conn.grafana_url || !conn.api_key) throw new NotFoundException('Grafana connection not configured');
|
||||
const baseUrl = conn.grafana_url.replace(/\/+$/, '');
|
||||
const resp = await fetch(`${baseUrl}/api/dashboards/uid/${body.dashboard_uid}`, {
|
||||
headers: { Authorization: `Bearer ${conn.api_key}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) throw new NotFoundException(`Grafana returned ${resp.status}: ${resp.statusText}`);
|
||||
const data = await resp.json();
|
||||
const panels: { id: string | number; title: string; type: string; section: string | null }[] = [];
|
||||
const extractPanels = (list: any[]) => {
|
||||
for (const p of list || []) {
|
||||
if (p.type === 'row') {
|
||||
const rowTitle = p.title || `Row ${p.id}`;
|
||||
panels.push({ id: `row-${p.id}`, title: `${rowTitle} (entire section)`, type: 'row', section: rowTitle });
|
||||
for (const child of p.panels || []) {
|
||||
panels.push({ id: child.id, title: child.title || `Panel ${child.id}`, type: child.type, section: rowTitle });
|
||||
}
|
||||
} else {
|
||||
panels.push({ id: p.id, title: p.title || `Panel ${p.id}`, type: p.type, section: null });
|
||||
}
|
||||
}
|
||||
};
|
||||
extractPanels(data.dashboard?.panels);
|
||||
return panels;
|
||||
}
|
||||
|
||||
@Post('grafana/generate-screen')
|
||||
@ApiOperation({ summary: 'Generate a Grafana screen (child instance)' })
|
||||
async grafanaGenerateScreen(@Body() body: {
|
||||
parentInstanceId: number;
|
||||
dashboard_uid: string;
|
||||
panel_id: number | string;
|
||||
time_range?: string;
|
||||
screen_width?: number;
|
||||
screen_height?: number;
|
||||
name?: string;
|
||||
}) {
|
||||
const parent = await this.pluginsService.findInstanceById(body.parentInstanceId);
|
||||
return this.pluginsService.createInstance({
|
||||
pluginId: parent.pluginId,
|
||||
name: body.name || 'Grafana Screen',
|
||||
settings: {
|
||||
parentInstanceId: body.parentInstanceId,
|
||||
dashboard_uid: body.dashboard_uid,
|
||||
panel_id: body.panel_id,
|
||||
time_range: body.time_range || 'now-6h',
|
||||
screen_width: body.screen_width || 800,
|
||||
screen_height: body.screen_height || 480,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export class PluginsModule implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.pluginsService.cleanupStalePlugins();
|
||||
await this.pluginsService.seedBuiltinPlugins();
|
||||
} catch {
|
||||
// Non-critical — skip if DB not ready
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as sharp from 'sharp';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PluginRendererService, PluginLayout } from './plugin-renderer.service';
|
||||
import { EncryptionService } from '../common/services/encryption.service';
|
||||
@@ -34,7 +35,10 @@ export class PluginsService {
|
||||
async findAllPlugins() {
|
||||
return this.prisma.plugin.findMany({
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
include: { _count: { select: { instances: true } } },
|
||||
include: {
|
||||
_count: { select: { instances: true } },
|
||||
instances: { select: { id: true, settings: true }, orderBy: { id: 'asc' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +48,10 @@ export class PluginsService {
|
||||
include: { instances: true },
|
||||
});
|
||||
if (!plugin) throw new NotFoundException(`Plugin ${id} not found`);
|
||||
return plugin;
|
||||
return {
|
||||
...plugin,
|
||||
instances: plugin.instances.map((i) => this.maskEncryptedSettings(i)),
|
||||
};
|
||||
}
|
||||
|
||||
async findPluginBySlug(slug: string) {
|
||||
@@ -86,10 +93,11 @@ export class PluginsService {
|
||||
// ========================
|
||||
|
||||
async findAllInstances() {
|
||||
return this.prisma.pluginInstance.findMany({
|
||||
const instances = await this.prisma.pluginInstance.findMany({
|
||||
include: { plugin: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return instances.map((i) => this.maskEncryptedSettings(i));
|
||||
}
|
||||
|
||||
async findInstanceById(id: number) {
|
||||
@@ -414,6 +422,87 @@ export class PluginsService {
|
||||
const settings = this.getDecryptedSettings(instance);
|
||||
const { width, height } = this.getDimensionsForLayout(layout);
|
||||
|
||||
// Grafana: screenshot the panel URL directly via Puppeteer
|
||||
if (plugin.slug === 'grafana_panel' && settings.dashboard_uid && settings.panel_id) {
|
||||
const conn = await this.getGrafanaConnection(instance);
|
||||
if (conn.grafana_url && conn.api_key) {
|
||||
const rw = Number(settings.screen_width) || width;
|
||||
const rh = Number(settings.screen_height) || height;
|
||||
const baseUrl = conn.grafana_url.replace(/\/+$/, '');
|
||||
const from = settings.time_range || 'now-6h';
|
||||
const panelId = String(settings.panel_id);
|
||||
let panelUrl: string;
|
||||
let evaluateScript: string | undefined;
|
||||
|
||||
// Script to strip all Grafana UI chrome
|
||||
const stripChromeScript = `
|
||||
// Hide dashboard controls (time picker, refresh, variables, links)
|
||||
document.querySelectorAll('[data-testid*="dashboard controls"], [data-testid*="template variable"], [data-testid*="Dashboard link"], [data-testid="public-dashboard-footer"]').forEach(el => el.style.display = 'none');
|
||||
// Hide all panel menu buttons (three-dot menus)
|
||||
document.querySelectorAll('[data-testid*="Panel menu"]').forEach(el => el.style.display = 'none');
|
||||
// Hide info icons in panel headers
|
||||
document.querySelectorAll('[data-testid*="icon-info-circle"]').forEach(el => el.style.display = 'none');
|
||||
document.body.style.overflow = 'hidden';
|
||||
`;
|
||||
|
||||
if (panelId === 'full') {
|
||||
panelUrl = `${baseUrl}/d/${settings.dashboard_uid}?orgId=1&from=${from}&to=now&theme=light&kiosk`;
|
||||
evaluateScript = stripChromeScript;
|
||||
} else if (panelId.startsWith('row-')) {
|
||||
// Entire section: render each panel individually then compose into a grid
|
||||
const rowIdNum = parseInt(panelId.replace('row-', ''), 10);
|
||||
const dashResp = await fetch(`${baseUrl}/api/dashboards/uid/${settings.dashboard_uid}`, {
|
||||
headers: { Authorization: `Bearer ${conn.api_key}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!dashResp.ok) throw new Error(`Grafana returned ${dashResp.status}`);
|
||||
const dashData = await dashResp.json();
|
||||
const allPanels = dashData.dashboard?.panels || [];
|
||||
const row = allPanels.find((p: any) => p.id === rowIdNum && p.type === 'row');
|
||||
if (!row) throw new Error(`Row ${rowIdNum} not found`);
|
||||
|
||||
// Collect child panel IDs — they may be nested (collapsed row) or siblings (expanded row)
|
||||
let childPanelIds: number[] = [];
|
||||
if (row.panels?.length) {
|
||||
// Collapsed row: panels are nested
|
||||
childPanelIds = row.panels.map((p: any) => p.id);
|
||||
} else {
|
||||
// Expanded row: panels are siblings between this row and the next row
|
||||
const rowIndex = allPanels.indexOf(row);
|
||||
for (let i = rowIndex + 1; i < allPanels.length; i++) {
|
||||
if (allPanels[i].type === 'row') break;
|
||||
childPanelIds.push(allPanels[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (childPanelIds.length === 0) throw new Error(`Row ${rowIdNum} has no panels`);
|
||||
this.logger.log(`[GrafanaSectionGrid] Row "${row.title}" has ${childPanelIds.length} panels: [${childPanelIds.join(', ')}]`);
|
||||
return this.renderGrafanaSectionGrid(baseUrl, settings.dashboard_uid, conn.api_key, childPanelIds, from, rw, rh, mode);
|
||||
} else {
|
||||
// Single panel
|
||||
panelUrl = `${baseUrl}/d-solo/${settings.dashboard_uid}?orgId=1&panelId=${panelId}&from=${from}&to=now&width=${rw}&height=${rh}&theme=light`;
|
||||
evaluateScript = `
|
||||
document.querySelectorAll('span').forEach(span => {
|
||||
if (/^Powered by$/i.test(span.textContent?.trim() || '')) {
|
||||
const container = span.parentElement;
|
||||
if (container) container.remove();
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('[data-testid*="icon-info-circle"]').forEach(el => el.style.display = 'none');
|
||||
`;
|
||||
}
|
||||
|
||||
return this.pluginRenderer.renderUrlToPng(
|
||||
panelUrl,
|
||||
{ Authorization: `Bearer ${conn.api_key}` },
|
||||
rw,
|
||||
rh,
|
||||
mode,
|
||||
evaluateScript,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
const locals = await this.fetchData(instanceId);
|
||||
|
||||
@@ -426,6 +515,109 @@ export class PluginsService {
|
||||
return this.pluginRenderer.renderToPng(markup, locals, settings, width, height, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Grafana section by screenshotting each panel individually
|
||||
* via /d-solo/ and compositing them into a grid that fills the target resolution.
|
||||
*/
|
||||
private async renderGrafanaSectionGrid(
|
||||
baseUrl: string,
|
||||
dashboardUid: string,
|
||||
apiKey: string,
|
||||
panelIds: number[],
|
||||
timeRange: string,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
mode: 'device' | 'preview' | 'einkPreview',
|
||||
): Promise<Buffer> {
|
||||
const count = panelIds.length;
|
||||
if (count === 0) throw new Error('No panels in section');
|
||||
|
||||
// Calculate optimal grid that fills the target resolution with minimal waste.
|
||||
// Prefer grids where cells are close to square and there are few empty cells.
|
||||
const targetAspect = targetWidth / targetHeight;
|
||||
let bestCols = 1;
|
||||
let bestScore = Infinity;
|
||||
for (let cols = 1; cols <= count; cols++) {
|
||||
const rows = Math.ceil(count / cols);
|
||||
const emptyCells = (cols * rows) - count;
|
||||
const cellW = targetWidth / cols;
|
||||
const cellH = targetHeight / rows;
|
||||
const cellAspect = cellW / cellH;
|
||||
// Penalize: deviation from square cells + wasted cells
|
||||
const aspectPenalty = Math.abs(Math.log(cellAspect)); // 0 when square
|
||||
const wastePenalty = emptyCells / count; // fraction of wasted cells
|
||||
const score = aspectPenalty + wastePenalty * 2;
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestCols = cols;
|
||||
}
|
||||
}
|
||||
const cols = bestCols;
|
||||
const rows = Math.ceil(count / cols);
|
||||
const cellWidth = Math.floor(targetWidth / cols);
|
||||
const cellHeight = Math.floor(targetHeight / rows);
|
||||
|
||||
this.logger.log(`[GrafanaSectionGrid] ${count} panels → ${cols}x${rows} grid, cell ${cellWidth}x${cellHeight}, target ${targetWidth}x${targetHeight}`);
|
||||
|
||||
// Screenshot panels with limited concurrency (max 4 parallel pages)
|
||||
const browser = await this.pluginRenderer.screenRenderer.getBrowser();
|
||||
const MAX_CONCURRENT = 4;
|
||||
const panelBuffers: Buffer[] = [];
|
||||
for (let i = 0; i < panelIds.length; i += MAX_CONCURRENT) {
|
||||
const batch = panelIds.slice(i, i + MAX_CONCURRENT);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (panelId) => {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setViewport({ width: cellWidth, height: cellHeight, deviceScaleFactor: 1 });
|
||||
await page.setExtraHTTPHeaders({ Authorization: `Bearer ${apiKey}` });
|
||||
const url = `${baseUrl}/d-solo/${dashboardUid}?orgId=1&panelId=${panelId}&from=${timeRange}&to=now&width=${cellWidth}&height=${cellHeight}&theme=light`;
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
// Strip "Powered by Grafana" overlay and other chrome
|
||||
await page.evaluate(() => {
|
||||
// The "Powered by" overlay is a div with a span containing "Powered by" + a Grafana logo img
|
||||
// It's positioned absolute with top/right. Find and remove it.
|
||||
document.querySelectorAll('span').forEach(span => {
|
||||
if (/^Powered by$/i.test(span.textContent?.trim() || '')) {
|
||||
const container = span.parentElement;
|
||||
if (container) container.remove();
|
||||
}
|
||||
});
|
||||
// Also hide info icons in panel headers
|
||||
document.querySelectorAll('[data-testid*="icon-info-circle"]').forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const png = Buffer.from(await page.screenshot({ type: 'png' }));
|
||||
// Resize to exact cell size
|
||||
return sharp(png).resize(cellWidth, cellHeight, { fit: 'fill' }).png().toBuffer();
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
panelBuffers.push(...batchResults);
|
||||
}
|
||||
|
||||
// Composite all panels into the grid
|
||||
const composites: sharp.OverlayOptions[] = panelBuffers.map((buf, i) => ({
|
||||
input: buf,
|
||||
left: (i % cols) * cellWidth,
|
||||
top: Math.floor(i / cols) * cellHeight,
|
||||
}));
|
||||
|
||||
const result = await sharp({
|
||||
create: { width: targetWidth, height: targetHeight, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
if (mode === 'preview') return result;
|
||||
|
||||
const shouldNegate = mode === 'device';
|
||||
return this.pluginRenderer.screenRenderer.applyEinkProcessing(result, targetWidth, targetHeight, shouldNegate);
|
||||
}
|
||||
|
||||
private async renderPluginPlaceholder(plugin: any, width: number, height: number): Promise<Buffer> {
|
||||
const category = (plugin.category || 'custom').charAt(0).toUpperCase() + (plugin.category || 'custom').slice(1);
|
||||
const source = (plugin.source || 'inker').toUpperCase();
|
||||
@@ -608,6 +800,148 @@ export class PluginsService {
|
||||
}));
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Grafana helpers
|
||||
// ========================
|
||||
|
||||
/**
|
||||
* Resolve Grafana connection settings for an instance.
|
||||
* Child instances have parentInstanceId → fetch parent's credentials.
|
||||
* Parent instances have credentials directly.
|
||||
*/
|
||||
async getGrafanaConnection(instance: any): Promise<{ grafana_url: string; api_key: string }> {
|
||||
const settings = this.getDecryptedSettings(instance);
|
||||
// If this instance has its own connection (parent)
|
||||
if (settings.grafana_url && settings.api_key) {
|
||||
return { grafana_url: settings.grafana_url, api_key: settings.api_key };
|
||||
}
|
||||
// Child instance — resolve from parent
|
||||
const parentId = (instance.settings as any)?.parentInstanceId;
|
||||
if (parentId) {
|
||||
const parent = await this.findInstanceById(parentId);
|
||||
const parentSettings = this.getDecryptedSettings(parent);
|
||||
return { grafana_url: parentSettings.grafana_url, api_key: parentSettings.api_key };
|
||||
}
|
||||
return { grafana_url: '', api_key: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Grafana connection from a parent instance ID (for controller use).
|
||||
*/
|
||||
async getGrafanaConnectionById(instanceId: number): Promise<{ grafana_url: string; api_key: string }> {
|
||||
const instance = await this.findInstanceById(instanceId);
|
||||
return this.getGrafanaConnection(instance);
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Builtin Plugins
|
||||
// ========================
|
||||
|
||||
async seedBuiltinPlugins(): Promise<void> {
|
||||
const builtins = [this.grafanaPluginDefinition()];
|
||||
for (const def of builtins) {
|
||||
await this.prisma.plugin.upsert({
|
||||
where: { slug: def.slug },
|
||||
create: def,
|
||||
update: {
|
||||
dataTransform: def.dataTransform,
|
||||
markupFull: def.markupFull,
|
||||
settingsSchema: def.settingsSchema,
|
||||
refreshInterval: def.refreshInterval,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
version: def.version,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.log(`Seeded ${builtins.length} builtin plugin(s)`);
|
||||
}
|
||||
|
||||
private grafanaPluginDefinition() {
|
||||
return {
|
||||
name: 'Grafana Panel',
|
||||
slug: 'grafana_panel',
|
||||
description: 'Display a Grafana dashboard panel on your e-ink screen. Requires the Grafana Image Renderer plugin.',
|
||||
icon: 'grafana',
|
||||
category: 'monitoring',
|
||||
source: 'inker',
|
||||
isBuiltin: true,
|
||||
dataStrategy: 'polling',
|
||||
refreshInterval: 300,
|
||||
version: '1.0.0',
|
||||
|
||||
settingsSchema: [
|
||||
{
|
||||
key: 'grafana_url',
|
||||
label: 'Grafana URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
description: 'Base URL of your Grafana instance (e.g. http://localhost:3000)',
|
||||
},
|
||||
{
|
||||
key: 'api_key',
|
||||
label: 'API Key / Service Account Token',
|
||||
type: 'password',
|
||||
required: true,
|
||||
encrypted: true,
|
||||
description: 'Grafana API key or service account token with Viewer role',
|
||||
},
|
||||
{
|
||||
key: 'dashboard_uid',
|
||||
label: 'Dashboard UID',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Found in the dashboard URL: /d/<uid>/...',
|
||||
},
|
||||
{
|
||||
key: 'panel_id',
|
||||
label: 'Panel ID',
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Found in panel URL parameter: viewPanel=<id>',
|
||||
},
|
||||
{
|
||||
key: 'time_range',
|
||||
label: 'Time Range',
|
||||
type: 'select',
|
||||
default: 'now-6h',
|
||||
options: [
|
||||
{ label: 'Last 1 hour', value: 'now-1h' },
|
||||
{ label: 'Last 6 hours', value: 'now-6h' },
|
||||
{ label: 'Last 12 hours', value: 'now-12h' },
|
||||
{ label: 'Last 24 hours', value: 'now-24h' },
|
||||
{ label: 'Last 7 days', value: 'now-7d' },
|
||||
{ label: 'Last 30 days', value: 'now-30d' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
dataTransform: [
|
||||
'// Rendering is handled by Puppeteer screenshot of Grafana panel URL',
|
||||
'return { dashboard_uid: settings.dashboard_uid, panel_id: settings.panel_id };',
|
||||
].join('\n'),
|
||||
|
||||
markupFull: [
|
||||
'<div class="view view--full">',
|
||||
' <div class="layout" style="padding:0; justify-content:center; align-items:center;">',
|
||||
' {% if image_base64 %}',
|
||||
' <img src="{{ image_base64 }}" style="width:800px; height:452px; object-fit:contain;" />',
|
||||
' {% else %}',
|
||||
' <div style="text-align:center; padding:32px;">',
|
||||
' <div class="title" style="font-size:24px;">Grafana Panel</div>',
|
||||
' <div class="label" style="margin-top:8px;">Configure your Grafana connection in plugin settings</div>',
|
||||
' </div>',
|
||||
' {% endif %}',
|
||||
' </div>',
|
||||
' <div class="title_bar">',
|
||||
' <span class="title">Grafana</span>',
|
||||
' <span class="instance">{{ dashboard_uid }} / panel {{ panel_id }}</span>',
|
||||
' </div>',
|
||||
'</div>',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Cleanup
|
||||
// ========================
|
||||
|
||||
@@ -685,6 +685,7 @@ export class ScreenDesignerController {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
|
||||
@@ -778,6 +779,7 @@ export class ScreenDesignerController {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export type RenderMode = 'device' | 'preview' | 'einkPreview';
|
||||
export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
private readonly logger = new Logger(ScreenRendererService.name);
|
||||
private browser: Browser | null = null;
|
||||
private browserLaunching: Promise<Browser> | null = null;
|
||||
private fontsBase64: Record<string, string> = {};
|
||||
private fontStyleTag: string = '';
|
||||
|
||||
@@ -165,7 +166,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
* Get or create Puppeteer browser instance
|
||||
* Handles browser reconnection if the browser crashes or disconnects
|
||||
*/
|
||||
private async getBrowser(): Promise<Browser> {
|
||||
async getBrowser(): Promise<Browser> {
|
||||
// Check if browser exists and is still connected
|
||||
if (this.browser) {
|
||||
try {
|
||||
@@ -182,33 +183,46 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
}
|
||||
|
||||
if (!this.browser) {
|
||||
this.logger.debug('Launching new Puppeteer browser instance');
|
||||
this.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--font-render-hinting=none',
|
||||
'--disable-font-subpixel-positioning',
|
||||
'--force-color-profile=srgb',
|
||||
// Network hardening — reduce Chrome's attack surface
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
],
|
||||
});
|
||||
// Prevent race condition: if another call is already launching, wait for it
|
||||
if (this.browserLaunching) {
|
||||
return this.browserLaunching;
|
||||
}
|
||||
|
||||
// Set up disconnect handler to reset browser reference
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Puppeteer browser disconnected unexpectedly');
|
||||
this.browser = null;
|
||||
});
|
||||
this.browserLaunching = (async () => {
|
||||
this.logger.debug('Launching new Puppeteer browser instance');
|
||||
this.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--font-render-hinting=none',
|
||||
'--disable-font-subpixel-positioning',
|
||||
'--force-color-profile=srgb',
|
||||
// Network hardening — reduce Chrome's attack surface
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--disable-translate',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
],
|
||||
});
|
||||
|
||||
// Set up disconnect handler to reset browser reference
|
||||
this.browser.on('disconnected', () => {
|
||||
this.logger.warn('Puppeteer browser disconnected unexpectedly');
|
||||
this.browser = null;
|
||||
this.browserLaunching = null;
|
||||
});
|
||||
|
||||
this.browserLaunching = null;
|
||||
return this.browser;
|
||||
})();
|
||||
|
||||
return this.browserLaunching;
|
||||
}
|
||||
return this.browser;
|
||||
}
|
||||
@@ -308,10 +322,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
// 'einkPreview' mode applies dithering but no inversion (for admin preview)
|
||||
const shouldNegate = mode === 'device';
|
||||
|
||||
// Create Sharp instance from the composited screenshot for e-ink processing
|
||||
const canvas = sharp(renderBuffer);
|
||||
|
||||
return this.applyEinkProcessing(canvas, width, height, shouldNegate);
|
||||
return this.applyEinkProcessing(renderBuffer, width, height, shouldNegate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +339,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
* @param negate - If true, invert colors (required for TRMNL e-ink devices)
|
||||
*/
|
||||
async applyEinkProcessing(
|
||||
canvas: Sharp,
|
||||
inputBuffer: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
negate: boolean,
|
||||
@@ -337,7 +348,8 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
const threshold = 140; // Higher threshold favors white
|
||||
|
||||
// First get grayscale raw pixels for Floyd-Steinberg dithering
|
||||
const grayBuffer = await canvas
|
||||
// Create fresh Sharp instance each time — Sharp pipelines are consumed after .toBuffer()
|
||||
const grayBuffer = await sharp(inputBuffer)
|
||||
.grayscale()
|
||||
.normalise()
|
||||
.raw()
|
||||
@@ -348,8 +360,9 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
// Apply Floyd-Steinberg dithering
|
||||
const ditheredBuffer = this.applyFloydSteinbergDithering(data, info.width, info.height, threshold);
|
||||
|
||||
// Output as standard 8-bit grayscale PNG (no palette mode)
|
||||
// Output as standard 8-bit grayscale PNG (color_type=0)
|
||||
// Firmware 1.7.8 handles display color mapping — palette PNGs cause scrambled display
|
||||
// Sharp outputs 1-channel raw as RGB by default, so we convert to grayscale colorspace
|
||||
let buffer = await sharp(ditheredBuffer, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
@@ -357,6 +370,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
|
||||
@@ -374,8 +388,8 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
`Screen too large (${buffer.length} bytes), scaling to ${newWidth}x${newHeight}`,
|
||||
);
|
||||
|
||||
// Re-render at smaller size
|
||||
const scaledGray = await canvas
|
||||
// Fresh Sharp instance from original buffer for each retry
|
||||
const scaledGray = await sharp(inputBuffer)
|
||||
.resize(newWidth, newHeight)
|
||||
.grayscale()
|
||||
.normalise()
|
||||
@@ -396,6 +410,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
@@ -437,17 +452,21 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
const error = oldPixel - newPixel;
|
||||
|
||||
// Error diffusion: 7/16, 3/16, 5/16, 1/16
|
||||
// Clamp after each addition to prevent error accumulation artifacts
|
||||
if (x + 1 < width) {
|
||||
pixels[idx + 1] += (error * 7) / 16;
|
||||
pixels[idx + 1] = Math.max(0, Math.min(255, pixels[idx + 1] + (error * 7) / 16));
|
||||
}
|
||||
if (x - 1 >= 0 && y + 1 < height) {
|
||||
pixels[(y + 1) * width + (x - 1)] += (error * 3) / 16;
|
||||
const i = (y + 1) * width + (x - 1);
|
||||
pixels[i] = Math.max(0, Math.min(255, pixels[i] + (error * 3) / 16));
|
||||
}
|
||||
if (y + 1 < height) {
|
||||
pixels[(y + 1) * width + x] += (error * 5) / 16;
|
||||
const i = (y + 1) * width + x;
|
||||
pixels[i] = Math.max(0, Math.min(255, pixels[i] + (error * 5) / 16));
|
||||
}
|
||||
if (x + 1 < width && y + 1 < height) {
|
||||
pixels[(y + 1) * width + (x + 1)] += (error * 1) / 16;
|
||||
const i = (y + 1) * width + (x + 1);
|
||||
pixels[i] = Math.max(0, Math.min(255, pixels[i] + (error * 1) / 16));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ export class ImageProcessorService {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.toColorspace('b-w')
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outputPath);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "inker-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { CustomWidgetForm, CustomWidgetPreview } from './pages/custom-widgets';
|
||||
import { Extensions } from './pages/extensions';
|
||||
// Plugin pages
|
||||
import { PluginLibrary, InstalledPlugins, PluginCreator, PluginInstanceForm, OAuthCallback } from './pages/plugins';
|
||||
import { GrafanaGeneratorPage } from './components/plugins/GrafanaGeneratorModal';
|
||||
|
||||
/**
|
||||
* Main App component with routing
|
||||
@@ -226,6 +227,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/instances/:instanceId/generate"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<GrafanaGeneratorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/instances/:id"
|
||||
element={
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user