0.4.0 — Grafana plugin UI, section grid rendering, security fixes

This commit is contained in:
wojo
2026-05-16 13:07:21 +00:00
parent f98fa1750d
commit 6b2b5989dd
31 changed files with 2347 additions and 159 deletions
+1 -1
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.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 -1
View File
@@ -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);
+10 -21
View File
@@ -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 () => {
+2 -15
View File
@@ -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
View File
@@ -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();
+112 -18
View File
@@ -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}`,
+37 -2
View File
@@ -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();
}
}
/**
+79
View File
@@ -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,
},
});
}
}
+1
View File
@@ -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
}
+337 -3
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "inker-frontend",
"private": true,
"version": "0.3.3",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "bunx --bun vite",
+9
View File
@@ -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