"feat: Added debug server and related components for improved development experience"

This commit is contained in:
2025-05-23 07:24:51 +02:00
parent d457a8d86b
commit 9f6219832c
189 changed files with 35730 additions and 133 deletions

View File

@@ -0,0 +1,54 @@
import type { printJobs } from "@/server/db/schema";
import type { InferResultType } from "@/utils/drizzle";
import type { InferSelectModel } from "drizzle-orm";
export interface PrinterErrorRate {
printerId: string;
name: string;
errorRate: number; // Error rate as a percentage (0-100)
}
/**
* Calculates the error rate for print jobs aggregated by printer as a percentage.
*
* @param pJobs - Array of print job objects.
* @returns An array of PrinterErrorRate objects, each containing the printer ID and its error rate.
*/
export function calculatePrinterErrorRate(
pJobs: InferResultType<"printJobs", { printer: true }>[],
): PrinterErrorRate[] {
if (pJobs.length === 0) {
return []; // No jobs, no data.
}
const printers = pJobs.map((job) => job.printer);
// Group jobs by printer ID
const jobsByPrinter: Record<string, InferSelectModel<typeof printJobs>[]> = pJobs.reduce(
(acc, job) => {
if (!acc[job.printerId]) {
acc[job.printerId] = [];
}
acc[job.printerId].push(job);
return acc;
},
{} as Record<string, InferSelectModel<typeof printJobs>[]>,
);
// Calculate the error rate for each printer
const printerErrorRates: PrinterErrorRate[] = Object.entries(jobsByPrinter).map(([printerId, jobs]) => {
const totalJobs = jobs.length;
const abortedJobsCount = jobs.filter((job) => job.aborted).length;
const errorRate = (abortedJobsCount / totalJobs) * 100;
const printer = printers.find((printer) => printer.id === printerId);
const printerName = printer ? printer.name : "Unbekannter Drucker";
return {
printerId,
name: printerName,
errorRate: Number.parseFloat(errorRate.toFixed(2)), // Rounded to two decimal places
};
});
return printerErrorRates;
}

View File

@@ -0,0 +1,39 @@
import type { InferResultType } from "@/utils/drizzle";
export interface AbortReasonCount {
abortReason: string;
count: number;
}
/**
* Calculates the count of each unique abort reason for print jobs.
*
* @param pJobs - Array of print job objects.
* @returns An array of AbortReasonCount objects, each containing the abort reason and its count.
*/
export function calculateAbortReasonsCount(pJobs: InferResultType<"printJobs">[]): AbortReasonCount[] {
if (pJobs.length === 0) {
return []; // No jobs, no data.
}
// Filter aborted jobs and count each abort reason
const abortReasonsCount = pJobs
.filter((job) => job.aborted && job.abortReason) // Consider only aborted jobs with a reason
.reduce(
(acc, job) => {
const reason = job.abortReason || "Unbekannter Grund";
if (!acc[reason]) {
acc[reason] = 0;
}
acc[reason]++;
return acc;
},
{} as Record<string, number>,
);
// Convert the result to an array of AbortReasonCount objects
return Object.entries(abortReasonsCount).map(([abortReason, count]) => ({
abortReason,
count,
}));
}

View File

@@ -0,0 +1,97 @@
import type { InferResultType } from "@/utils/drizzle";
type UsagePerDay = {
day: number; // 0 (Sunday) to 6 (Saturday)
usageMinutes: number;
};
function aggregateUsageByDay(jobs: InferResultType<"printJobs">[]): {
usageData: UsagePerDay[];
earliestDate: Date;
latestDate: Date;
} {
const usagePerDayMap = new Map<number, number>();
let earliestDate: Date | null = null;
let latestDate: Date | null = null;
for (const job of jobs) {
let remainingDuration = job.durationInMinutes;
const currentStart = new Date(job.startAt);
// Update earliest and latest dates
if (!earliestDate || currentStart < earliestDate) {
earliestDate = new Date(currentStart);
}
const jobEnd = new Date(currentStart);
jobEnd.setMinutes(jobEnd.getMinutes() + job.durationInMinutes);
if (!latestDate || jobEnd > latestDate) {
latestDate = new Date(jobEnd);
}
while (remainingDuration > 0) {
const day = currentStart.getDay();
// Calculate minutes remaining in the current day
const minutesRemainingInDay = (24 - currentStart.getHours()) * 60 - currentStart.getMinutes();
const minutesToAdd = Math.min(remainingDuration, minutesRemainingInDay);
// Update the usage for the current day
const usageMinutes = usagePerDayMap.get(day) || 0;
usagePerDayMap.set(day, usageMinutes + minutesToAdd);
// Update remaining duration and move to the next day
remainingDuration -= minutesToAdd;
currentStart.setDate(currentStart.getDate() + 1);
currentStart.setHours(0, 0, 0, 0); // Start at the beginning of the next day
}
}
const usageData: UsagePerDay[] = Array.from({ length: 7 }, (_, day) => ({
day,
usageMinutes: usagePerDayMap.get(day) || 0,
}));
if (earliestDate === null) {
earliestDate = new Date();
}
if (latestDate === null) {
latestDate = new Date();
}
return { usageData, earliestDate: earliestDate, latestDate: latestDate };
}
function countWeekdays(startDate: Date, endDate: Date): number[] {
const countPerDay = Array(7).fill(0);
const currentDate = new Date(startDate);
currentDate.setHours(0, 0, 0, 0); // Ensure starting at midnight
endDate.setHours(0, 0, 0, 0); // Ensure ending at midnight
while (currentDate <= endDate) {
const day = currentDate.getDay();
countPerDay[day]++;
currentDate.setDate(currentDate.getDate() + 1);
}
return countPerDay;
}
export function forecastPrinterUsage(jobs: InferResultType<"printJobs">[]): number[] {
const { usageData, earliestDate, latestDate } = aggregateUsageByDay(jobs);
// Count the number of times each weekday occurs in the data period
const weekdaysCount = countWeekdays(earliestDate, latestDate);
const forecasts: number[] = [];
for (const data of usageData) {
const dayCount = weekdaysCount[data.day];
let usagePrediction = data.usageMinutes / dayCount;
if (Number.isNaN(usagePrediction)) {
usagePrediction = 0;
}
forecasts.push(Math.round(usagePrediction));
}
return forecasts;
}

View File

@@ -0,0 +1,32 @@
import type { InferResultType } from "@/utils/drizzle";
export function calculatePrinterUtilization(jobs: InferResultType<"printJobs", { printer: true }>[]) {
const printers = jobs.reduce<Record<string, string>>((acc, job) => {
acc[job.printerId] = job.printer.name;
return acc;
}, {});
const usedTimePerPrinter: Record<string, number> = jobs.reduce(
(acc, job) => {
acc[job.printer.id] = (acc[job.printer.id] || 0) + job.durationInMinutes;
return acc;
},
{} as Record<string, number>,
);
const totalTimeInMinutes = 60 * 35 * 3; // 60 Minutes * 35h * 3 Weeks
// 35h Woche, 3 mal in der Woche in TBA
const printerUtilizationPercentage = Object.keys(usedTimePerPrinter).map((printerId) => {
const usedTime = usedTimePerPrinter[printerId];
return {
printerId,
name: printers[printerId],
utilizationPercentage: usedTime / totalTimeInMinutes,
};
});
return printerUtilizationPercentage;
}

View File

@@ -0,0 +1,52 @@
import type { printJobs } from "@/server/db/schema";
import { endOfDay, endOfMonth, endOfWeek, startOfDay, startOfMonth, startOfWeek } from "date-fns";
import type { InferSelectModel } from "drizzle-orm";
interface PrintVolumes {
today: number;
thisWeek: number;
thisMonth: number;
}
/**
* Calculates the number of print jobs for today, this week, and this month.
*
* @param printJobs - Array of print job objects.
* @returns An object with counts of print jobs for today, this week, and this month.
*/
export function calculatePrintVolumes(pJobs: InferSelectModel<typeof printJobs>[]): PrintVolumes {
const now = new Date();
// Define time ranges with week starting on Monday
const timeRanges = {
today: { start: startOfDay(now), end: endOfDay(now) },
thisWeek: {
start: startOfWeek(now, { weekStartsOn: 1 }),
end: endOfWeek(now, { weekStartsOn: 1 }),
},
thisMonth: { start: startOfMonth(now), end: endOfMonth(now) },
};
// Initialize counts
const volumes: PrintVolumes = {
today: 0,
thisWeek: 0,
thisMonth: 0,
};
// Iterate over print jobs and count based on time ranges
for (const job of pJobs) {
const jobStart = new Date(job.startAt);
if (jobStart >= timeRanges.today.start && jobStart <= timeRanges.today.end) {
volumes.today += 1;
}
if (jobStart >= timeRanges.thisWeek.start && jobStart <= timeRanges.thisWeek.end) {
volumes.thisWeek += 1;
}
if (jobStart >= timeRanges.thisMonth.start && jobStart <= timeRanges.thisMonth.end) {
volumes.thisMonth += 1;
}
}
return volumes;
}

View File

@@ -0,0 +1,51 @@
// Basis-URL für Backend-API
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://192.168.0.105:5000";
// Frontend-URL für Callbacks - unterstützt mehrere Domains
const getFrontendUrl = () => {
// Priorität 1: Explizit gesetzte Umgebungsvariable
if (process.env.NEXT_PUBLIC_FRONTEND_URL) {
return process.env.NEXT_PUBLIC_FRONTEND_URL;
}
// Priorität 2: Spezifischer Hostname für das Netzwerk
if (typeof window !== 'undefined') {
// Im Browser: Prüfen auf m040tbaraspi001.de040.corpintra.net
const hostname = window.location.hostname;
if (hostname === 'm040tbaraspi001' ||
hostname === 'm040tbaraspi001.de040.corpintra.net' ||
hostname.includes('corpintra.net')) {
return `http://${hostname}`;
}
}
// Priorität 3: Default für Localhost
return "http://localhost:3000";
};
export const FRONTEND_URL = getFrontendUrl();
// OAuth Callback URL - muss exakt mit der registrierten URL in GitHub übereinstimmen
export const OAUTH_CALLBACK_URL = process.env.NEXT_PUBLIC_OAUTH_CALLBACK_URL ||
`${FRONTEND_URL}/auth/login/callback`;
// Liste der erlaubten Hostnamen für OAuth-Callbacks
export const ALLOWED_CALLBACK_HOSTS = [
'localhost',
'm040tbaraspi001',
'm040tbaraspi001.de040.corpintra.net',
'192.168.0.105'
];
// Endpunkte für die verschiedenen Ressourcen
export const API_ENDPOINTS = {
PRINTERS: `${API_BASE_URL}/api/printers`,
JOBS: `${API_BASE_URL}/api/jobs`,
USERS: `${API_BASE_URL}/api/users`,
// OAuth-spezifische Endpunkte
AUTH: {
LOGIN: `${API_BASE_URL}/api/auth/login`,
CALLBACK: `${API_BASE_URL}/api/auth/callback`,
}
};

View File

@@ -0,0 +1,29 @@
import type * as schema from "@/server/db/schema";
import type { BuildQueryResult, DBQueryConfig, ExtractTablesWithRelations } from "drizzle-orm";
type Schema = typeof schema;
type TSchema = ExtractTablesWithRelations<Schema>;
/**
* Infer the relation type of a table.
*/
export type IncludeRelation<TableName extends keyof TSchema> = DBQueryConfig<
"one" | "many",
boolean,
TSchema,
TSchema[TableName]
>["with"];
/**
* Infer the result type of a query with optional relations.
*/
export type InferResultType<
TableName extends keyof TSchema,
With extends IncludeRelation<TableName> | undefined = undefined,
> = BuildQueryResult<
TSchema,
TSchema[TableName],
{
with: With;
}
>;

View File

@@ -0,0 +1,59 @@
import strings from "@/utils/strings";
/**
* Base error class.
*/
class BaseError extends Error {
constructor(message?: string) {
// Pass the message to the Error constructor
super(message);
// Set the name of the error
this.name = this.constructor.name;
// Capture the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(message).stack;
}
}
}
/**
* Permission error class.
*/
export class PermissionError extends BaseError {
constructor() {
super(strings.ERROR.PERMISSION);
}
}
/**
* Authentication error class.
*/
export class AuthenticationError extends BaseError {
constructor() {
super(strings.ERROR.NO_SESSION);
}
}
/**
* Validation error class.
*/
export class ValidationError extends BaseError {
constructor() {
super(strings.ERROR.VALIDATION);
}
}
/**
* Not found error class.
*/
export class NotFoundError extends BaseError {
constructor() {
super(strings.ERROR.NOT_FOUND);
}
}
export default BaseError;

View File

@@ -0,0 +1,78 @@
import { API_ENDPOINTS } from './api-config';
// Typdefinitionen für API-Responses
export interface Printer {
id: string;
name: string;
ip: string;
status: string;
is_enabled: boolean;
}
export interface Job {
id: string;
printer_id: string;
user_id: string;
start_time: string;
end_time: string;
status: string;
}
// Fetcher für SWR mit Fehlerbehandlung
const fetchWithErrorHandling = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
const error = new Error('Ein Fehler ist bei der API-Anfrage aufgetreten');
throw error;
}
return response.json();
};
// API-Funktionen
export const api = {
// Drucker-Endpunkte
printers: {
getAll: () => fetchWithErrorHandling(API_ENDPOINTS.PRINTERS),
getById: (id: string) => fetchWithErrorHandling(`${API_ENDPOINTS.PRINTERS}/${id}`),
create: (data: Partial<Printer>) =>
fetch(API_ENDPOINTS.PRINTERS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
update: (id: string, data: Partial<Printer>) =>
fetch(`${API_ENDPOINTS.PRINTERS}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
delete: (id: string) =>
fetch(`${API_ENDPOINTS.PRINTERS}/${id}`, {
method: 'DELETE',
}).then(res => res.json()),
},
// Jobs-Endpunkte
jobs: {
getAll: () => fetchWithErrorHandling(API_ENDPOINTS.JOBS),
getById: (id: string) => fetchWithErrorHandling(`${API_ENDPOINTS.JOBS}/${id}`),
create: (data: Partial<Job>) =>
fetch(API_ENDPOINTS.JOBS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
update: (id: string, data: Partial<Job>) =>
fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
delete: (id: string) =>
fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'DELETE',
}).then(res => res.json()),
},
};

View File

@@ -0,0 +1 @@
export const fetcher = (url: string) => fetch(url).then((response) => response.json());

View File

@@ -0,0 +1,35 @@
import type { UserRole } from "@/server/auth/permissions";
import type { users } from "@/server/db/schema";
import type { InferSelectModel } from "drizzle-orm";
import type { RegisteredDatabaseUserAttributes } from "lucia";
// Constants for better readability
export const IS = false;
export const IS_NOT = true;
/**
* Checks if a user has the required role(s).
* @param user - The user to check.
* @param negate - Whether to negate the result.
* @param roleRequirements - The required role(s).
* @returns Whether the user has the required role(s).
*/
export function guard(
user: RegisteredDatabaseUserAttributes | InferSelectModel<typeof users> | undefined | null,
negate: boolean,
roleRequirements: UserRole | UserRole[],
) {
// Early return for unauthenticated users
if (!user) {
return true;
}
// Normalize roleRequirements to an array
const requiredRoles = Array.isArray(roleRequirements) ? roleRequirements : [roleRequirements];
// Check if the user's role is in the required roles
const userHasRequiredRole = requiredRoles.includes(user.role as UserRole);
// Return the result, negated if necessary
return negate ? !userHasRequiredRole : userHasRequiredRole;
}

View File

@@ -0,0 +1,41 @@
import type { InferResultType } from "@/utils/drizzle";
export enum PrinterStatus {
IDLE = 0,
OUT_OF_ORDER = 1,
RESERVED = 2,
}
export function derivePrinterStatus(
printer: InferResultType<"printers", { printJobs: true }>,
) {
if (printer.status === PrinterStatus.OUT_OF_ORDER) {
return PrinterStatus.OUT_OF_ORDER;
}
const activePrintJob = printer.printJobs[0];
if (!activePrintJob || activePrintJob.aborted) {
return PrinterStatus.IDLE;
}
const now = Date.now();
const startAt = new Date(activePrintJob.startAt).getTime();
const endAt = startAt + activePrintJob.durationInMinutes * 60 * 1000;
if (now < endAt) {
return PrinterStatus.RESERVED;
}
return PrinterStatus.IDLE;
}
export function translatePrinterStatus(status: PrinterStatus) {
switch (status) {
case PrinterStatus.IDLE:
return "Verfügbar";
case PrinterStatus.OUT_OF_ORDER:
return "Außer Betrieb";
case PrinterStatus.RESERVED:
return "Reserviert";
}
}

View File

@@ -0,0 +1,11 @@
/**
* Contains all strings used in the application.
*/
export default {
ERROR: {
PERMISSION: "Du besitzt nicht die erforderlichen Berechtigungen um diese Aktion auszuführen.",
VALIDATION: "Die Eingabe ist ungültig.",
NOT_FOUND: "Die angeforderten Daten konnten nicht gefunden werden.",
NO_SESSION: "Du bist nicht angemeldet.",
},
};

View File

@@ -0,0 +1,11 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Utility function to merge classes with tailwindcss.
* @param inputs Classes to merge
* @returns classes
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}