Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ NEXTAUTH_URL=http://localhost:3000
# NEXTAUTH_URL) — webhooks don't fire into localhost dev.
MOLLIE_API_KEY=

# --- Fleet telemetry roll-up (POST /api/telemetry/fleet) ---
# Shared bearer secret each tenant backend's FleetSummaryPushService must present
# (openssl rand -hex 32). Must match the same secret on the backend side. Unset -> the route 503s.
PRINTER_TELEMETRY_SECRET=

# --- Data retention sweep (GDPR storage-limitation; POST /api/cron/retention) ---
# Shared bearer secret the cron caller must present (openssl rand -hex 32). Must
# match the CRON_SECRET GitHub secret on piwas-21/sofra. Unset -> the route 503s.
Expand Down
181 changes: 181 additions & 0 deletions app/(control)/admin/fleet/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { getTranslations } from "next-intl/server";
import { requireAdmin } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
import { loadTenantRegistry, type RegistryTenant } from "@/lib/tenant-registry";

// The registry file changes underneath us (rsync on deploy-repo push) — always re-read.
export const dynamic = "force-dynamic";

// Presentation-layer liveness thresholds (the backend stores raw facts; "online"/"stale" is derived
// here, per the fleet plan). A device heartbeats ~every 60s and the backend rolls up every few min.
const ONLINE_WINDOW_MS = 5 * 60 * 1000; // no heartbeat in 5 min ⇒ offline
const STALE_FEED_MS = 5 * 60 * 1000; // feed "running" but no successful poll in 5 min ⇒ wedged

type Translator = (key: string, values?: Record<string, string | number>) => string;

type FleetDeviceRow = {
deviceId: string;
label: string | null;
platform: string | null;
appVersion: string | null;
feedRunning: boolean;
lastHeartbeatAt: Date | null;
lastSuccessfulPollAt: Date | null;
};

type FleetSummaryRow = { missedOrders: number; recentErrors: number };

function livenessLabel(device: FleetDeviceRow, now: number, t: Translator): { label: string; online: boolean } {
const online = device.lastHeartbeatAt != null && now - device.lastHeartbeatAt.getTime() < ONLINE_WINDOW_MS;
if (online) return { label: t("device.online"), online };
if (device.lastHeartbeatAt == null) return { label: t("device.neverSeen"), online };
const minutes = Math.max(0, Math.round((now - device.lastHeartbeatAt.getTime()) / 60000));
return { label: t("device.offlineSince", { minutes }), online };
}

function feedStatus(device: FleetDeviceRow, now: number, t: Translator): { label: string; ok: boolean } {
const stale =
device.feedRunning &&
(device.lastSuccessfulPollAt == null || now - device.lastSuccessfulPollAt.getTime() > STALE_FEED_MS);
if (!device.feedRunning) return { label: t("device.feedStopped"), ok: false };
if (stale) return { label: t("device.feedStale"), ok: false };
return { label: t("device.feedRunning"), ok: true };
}

function DeviceRow({ device, now, t }: Readonly<{ device: FleetDeviceRow; now: number; t: Translator }>) {
const liveness = livenessLabel(device, now, t);
const feed = feedStatus(device, now, t);
const feedTone = feed.ok
? "text-craft-success-text dark:text-craft-success"
: "text-craft-error-text dark:text-craft-error";

return (
<li className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-2 first:border-t-0 first:pt-0">
<span>
<span className="font-label font-bold">{device.label ?? device.deviceId.slice(0, 8)}</span>
<span className="ml-2 font-label text-sm text-muted-foreground">
{device.platform ?? "—"} · v{device.appVersion ?? "—"}
</span>
</span>
<span className="font-label text-sm text-right">
<span className={liveness.online ? "text-craft-success-text dark:text-craft-success" : "text-muted-foreground"}>
{liveness.label}
</span>
<span className={`ml-3 ${feedTone}`}>{feed.label}</span>
</span>
</li>
);
}

function TenantCard({
tenant,
devices,
summary,
now,
t,
}: Readonly<{
tenant: RegistryTenant;
devices: FleetDeviceRow[];
summary?: FleetSummaryRow;
now: number;
t: Translator;
}>) {
return (
<li className="hand-drawn-border bg-card p-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<span>
<span className="font-hand text-2xl font-bold">{tenant.slug}</span>
<span className="ml-3 font-label text-sm text-muted-foreground">
{tenant.name}
{tenant.city ? ` · ${tenant.city}` : ""}
</span>
</span>
{summary && (
<span className="font-label text-sm text-right text-muted-foreground">
{t("summary.missed", { count: summary.missedOrders })} ·{" "}
{t("summary.errors", { count: summary.recentErrors })}
</span>
)}
</div>

{devices.length === 0 ? (
<p className="mt-3 font-label text-sm text-muted-foreground">{t("tenant.noReport")}</p>
) : (
<ul className="mt-3 grid gap-2">
{devices.map((d) => (
<DeviceRow key={d.deviceId} device={d} now={now} t={t} />
))}
</ul>
)}
</li>
);
}

function FleetHeader({ t }: Readonly<{ t: Translator }>) {
return (
<div>
<h1 className="font-display font-bold text-5xl">{t("title")}</h1>
<p className="mt-2 font-label text-muted-foreground">{t("intro")}</p>
</div>
);
}

export default async function AdminFleetPage() {
await requireAdmin();
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.admin.fleet" });
const registry = await loadTenantRegistry();

// Registry unavailable ⇒ bail before the DB reads (nothing to join to).
if (!registry.ok) {
return (
<div className="grid gap-10">
<FleetHeader t={t} />
<p className="hand-drawn-border bg-card p-4 font-label text-craft-error-text">
{t("unavailable", { error: registry.error })}
</p>
</div>
);
}

const [devices, summaries] = await Promise.all([
db.fleetDevice.findMany({ orderBy: [{ tenantSlug: "asc" }, { label: "asc" }] }),
db.fleetSummary.findMany(),
]);
Comment thread
mahmutkaya marked this conversation as resolved.

const devicesBySlug = new Map<string, FleetDeviceRow[]>();
for (const d of devices) {
const list = devicesBySlug.get(d.tenantSlug) ?? [];
list.push(d);
devicesBySlug.set(d.tenantSlug, list);
}
const summaryBySlug = new Map(summaries.map((s) => [s.tenantSlug, s]));
const now = Date.now();

return (
<div className="grid gap-10">
<FleetHeader t={t} />
<section>
<h2 className="font-hand text-3xl font-bold">
{t("registered", { count: registry.tenants.length })}
</h2>
<ul className="mt-4 grid gap-4">
{registry.tenants.map((tenant) => (
<TenantCard
key={tenant.slug}
tenant={tenant}
devices={devicesBySlug.get(tenant.slug) ?? []}
summary={summaryBySlug.get(tenant.slug)}
now={now}
t={t}
/>
))}
{registry.tenants.length === 0 && (
<li className="font-label text-muted-foreground">{t("empty")}</li>
)}
</ul>
</section>
</div>
);
}
1 change: 1 addition & 0 deletions app/(control)/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{ href: "/admin/partners", label: t("nav.partners") },
{ href: "/admin/clients", label: t("nav.clients") },
{ href: "/admin/tenants", label: t("nav.tenants") },
{ href: "/admin/fleet", label: t("nav.fleet") },
{ href: "/admin/provision", label: t("nav.provision") },
{ href: "/admin/onboard", label: t("nav.onboard") },
{ href: "/admin/billing", label: t("nav.billing") },
Expand Down
49 changes: 49 additions & 0 deletions app/api/telemetry/fleet/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { createHash, timingSafeEqual } from "node:crypto";
import { fleetPushSchema, ingestFleetPush } from "@/lib/fleet";

// Machine-to-machine ingest for the fleet roll-up: each tenant backend's FleetSummaryPushService
// POSTs a compact per-tenant snapshot here. NOT a (control) surface — no RBAC session; guarded by a
// shared PRINTER_TELEMETRY_SECRET bearer token. One-directional (backend → sofra): devices/backends
// hold no sofra credential, sofra holds no backend credential. Non-PII (schema-enforced).
//
// TRUST MODEL (follow-up): the bearer is a single shared machine secret and the tenant is taken from
// the body's tenantSlug, so any secret-holder could write/prune another tenant's rows. Blast radius
// is bounded — internal admin-only view, non-PII, self-heals on the next legitimate push, one tenant
// today. When the backend push slice lands, bind the slug to a per-tenant credential.

// Constant-time bearer check. Both sides are SHA-256'd to a fixed 32 bytes first so timingSafeEqual
// never sees a length mismatch (which would throw and leak the secret's length via timing).
function authorized(request: Request): boolean {
const secret = process.env.PRINTER_TELEMETRY_SECRET;
if (!secret) return false;
const digest = (s: string) => createHash("sha256").update(s).digest();
const provided = digest(request.headers.get("authorization") ?? "");
const expected = digest(`Bearer ${secret}`);
return timingSafeEqual(provided, expected);
}

export async function POST(request: Request) {
if (!process.env.PRINTER_TELEMETRY_SECRET) {
return NextResponse.json({ error: "fleet telemetry not configured" }, { status: 503 });
}
if (!authorized(request)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}

const parsed = fleetPushSchema.safeParse(body);
if (!parsed.success) {
// Never echo the payload — just that it was malformed.
return NextResponse.json({ error: "invalid payload" }, { status: 400 });
}

await ingestFleetPush(parsed.data);
return NextResponse.json({ ok: true, devices: parsed.data.devices.length });
}
94 changes: 94 additions & 0 deletions lib/fleet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { z } from "zod";
import { db } from "@/lib/db";

// Payload pushed by a tenant backend's FleetSummaryPushService (one-directional, bearer-authed).
// Non-PII only: device roster + counts, never customer data / API keys / raw orders.
export const fleetDeviceSchema = z.object({
deviceId: z.string().trim().min(1).max(64),
label: z.string().trim().max(120).nullish(),
platform: z.string().trim().max(40).nullish(),
appVersion: z.string().trim().max(40).nullish(),
feedRunning: z.boolean().default(false),
lastHeartbeatAt: z.coerce.date().nullish(),
lastSuccessfulPollAt: z.coerce.date().nullish(),
apiBaseUrl: z.string().trim().max(300).nullish(),
kitchenPrinter: z.string().trim().max(200).nullish(),
cashierPrinter: z.string().trim().max(200).nullish(),
});

export const fleetPushSchema = z.object({
tenantSlug: z.string().trim().min(1).max(80),
reportedAt: z.coerce.date(),
missedOrders: z.number().int().min(0).max(1_000_000).default(0),
recentErrors: z.number().int().min(0).max(1_000_000).default(0),
devices: z.array(fleetDeviceSchema).max(200),
});

export type FleetPush = z.infer<typeof fleetPushSchema>;
type FleetDeviceInput = z.infer<typeof fleetDeviceSchema>;

function deviceColumns(d: FleetDeviceInput) {
return {
label: d.label ?? null,
platform: d.platform ?? null,
appVersion: d.appVersion ?? null,
feedRunning: d.feedRunning,
lastHeartbeatAt: d.lastHeartbeatAt ?? null,
lastSuccessfulPollAt: d.lastSuccessfulPollAt ?? null,
apiBaseUrl: d.apiBaseUrl ?? null,
kitchenPrinter: d.kitchenPrinter ?? null,
cashierPrinter: d.cashierPrinter ?? null,
};
}

// Upsert the tenant's summary + reconcile its device roster (upsert reported, prune the rest) in
// one transaction, so a partial failure can't leave a half-updated fleet view.
export async function ingestFleetPush(push: FleetPush): Promise<void> {
const slug = push.tenantSlug;
const now = new Date();
// Dedup by deviceId (a repeated id in one push would upsert the same row twice) and sort
// deterministically, so concurrent same-tenant pushes acquire row locks in the same order and
// can't deadlock. A later duplicate wins (Map keeps the last value for a key).
const devices = Array.from(new Map(push.devices.map((d) => [d.deviceId, d])).values()).sort((a, b) =>
a.deviceId.localeCompare(b.deviceId),
);
const reportedIds = devices.map((d) => d.deviceId);

await db.$transaction(async (tx) => {
await tx.fleetSummary.upsert({
where: { tenantSlug: slug },
create: {
tenantSlug: slug,
deviceCount: devices.length,
missedOrders: push.missedOrders,
recentErrors: push.recentErrors,
reportedAt: push.reportedAt,
receivedAt: now,
},
update: {
deviceCount: devices.length,
missedOrders: push.missedOrders,
recentErrors: push.recentErrors,
reportedAt: push.reportedAt,
receivedAt: now,
},
});

// Prune devices this tenant no longer reports (decommissioned) — scoped to THIS tenant only.
// An empty roster drops them all; otherwise keep only the reported ids.
if (reportedIds.length === 0) {
await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug } });
} else {
await tx.fleetDevice.deleteMany({ where: { tenantSlug: slug, deviceId: { notIn: reportedIds } } });
}

for (const d of devices) {
const columns = deviceColumns(d);
await tx.fleetDevice.upsert({
where: { tenantSlug_deviceId: { tenantSlug: slug, deviceId: d.deviceId } },
create: { tenantSlug: slug, deviceId: d.deviceId, ...columns },
update: columns,
});
}
});
}
25 changes: 24 additions & 1 deletion messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,8 @@
"onboard": "الإعداد",
"plan": "الاشتراك",
"overview": "نظرة عامة",
"signups": "التسجيلات"
"signups": "التسجيلات",
"fleet": "الأسطول"
}
},
"status": {
Expand Down Expand Up @@ -726,6 +727,28 @@
"creating": "جارٍ الفتح…",
"created": "تم فتح طلب سحب للسجل — راجعه وادمجه، ثم زوّد:",
"nextSteps": "بعد الدمج: تتزامن التغييرات مع الخادم في المزامنة التالية للنشر، ثم شغّل إجراء provision-tenant بهذا المعرّف."
},
"fleet": {
"title": "أسطول الطابعات",
"intro": "الحالة المباشرة لتطبيقات الطباعة لكل مستأجر — نشاط الأجهزة، وتدفق الطلبات، والطلبات الفائتة.",
"unavailable": "سجل المستأجرين غير متاح: {error}",
"registered": "{count} مستأجرين",
"empty": "لا يوجد مستأجرون مسجلون بعد.",
"tenant": {
"noReport": "لم تُبلّغ أي أجهزة بعد."
},
"device": {
"online": "متصل",
"offlineSince": "غير متصل · قبل {minutes} دقيقة",
"neverSeen": "لم يُشاهد قط",
"feedRunning": "التدفق يعمل",
"feedStopped": "التدفق متوقف",
"feedStale": "التدفق معلّق"
},
"summary": {
"missed": "{count} فائتة",
"errors": "{count} أخطاء"
}
}
},
"errors": {
Expand Down
Loading
Loading