diff --git a/.gitignore b/.gitignore index 8f322f0..309ba42 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,8 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local +.env +*.local # vercel .vercel diff --git a/Cron-Docker/Dockerfile b/Cron-Docker/Dockerfile new file mode 100644 index 0000000..ca3f598 --- /dev/null +++ b/Cron-Docker/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:latest + +# Install necessary software +RUN apt-get update && \ + apt-get -y install cron curl + +# Add crontab file +COPY crontab /etc/cron.d/crontab + +# Give execution rights on the cron job +RUN chmod 0644 /etc/cron.d/crontab + +# Create the log file to be able to run tail +RUN touch /var/log/cron.log + +# Start the cron service +CMD cron && tail -f /var/log/cron.log diff --git a/Cron-Docker/crontab b/Cron-Docker/crontab new file mode 100644 index 0000000..92873de --- /dev/null +++ b/Cron-Docker/crontab @@ -0,0 +1 @@ +* * * * * curl localhost:3000/api/cron/update-controllers > /var/log/cron.log \ No newline at end of file diff --git a/app/api/controllers/[cid]/route.ts b/app/api/controllers/[cid]/route.ts new file mode 100644 index 0000000..65607f7 --- /dev/null +++ b/app/api/controllers/[cid]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET( + request: Request, + { params }: { params: { cid: string } } +) { + try { + const sessions = await prisma.controllerSession.findMany({ + where: { + cid: params.cid, + }, + orderBy: { + lastSeen: 'desc', + }, + }) + + return NextResponse.json(sessions) + } catch (error) { + console.error('Failed to fetch controller sessions:', error) + return NextResponse.json({ error: 'Failed to fetch controller sessions' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/controllers/route.ts b/app/api/controllers/route.ts index 9ba24ec..d95f131 100644 --- a/app/api/controllers/route.ts +++ b/app/api/controllers/route.ts @@ -1,104 +1,19 @@ -import { NextResponse } from 'next/server'; - -interface Controller { - callsign: string; - frequency: string; - facility: number; -} - -const facilityTypes = { - 0: "OBS", - 1: "FSS", - 2: "DEL", - 3: "GND", - 4: "TWR", - 5: "APP", - 6: "CTR", -} as const; - -const facilityLongNames = { - OBS: "Observer", - FSS: "Flight Service Station", - DEL: "Clearance Delivery", - GND: "Ground", - TWR: "Tower", - APP: "Approach/Departure", - CTR: "Centre", -} as const; - -// Define FIR coverage for airports -const firCoverage: Record = { - CZQM: ["CYHZ", "CYFC", "CYQM", "CYSJ", "CYZX", "CYYG", "CYYT", "CYQX", "CYYR", "LFVP", "CYQI", "CYAY", "CYDF", "CYJT"], - CZUL: ["CYZV"], -}; +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' export async function GET() { try { - const response = await fetch('https://data.vatsim.net/v3/vatsim-data.json', { - next: { revalidate: 60 } - }); - - if (!response.ok) { - throw new Error('Failed to fetch VATSIM data'); - } + // Get unique controllers with their latest session + const controllers = await prisma.controllerSession.findMany({ + orderBy: { + lastSeen: 'desc', + }, + distinct: ['cid'], + }) - const data = await response.json(); - - // First, get all controllers including CTR - const allControllers = data.controllers.map((controller: Controller) => { - const shortFacility = facilityTypes[controller.facility as keyof typeof facilityTypes] || "Unknown"; - return { - callsign: controller.callsign, - frequency: controller.frequency, - facility: shortFacility, - facilityLong: facilityLongNames[shortFacility as keyof typeof facilityLongNames] || "Unknown", - facilityType: controller.facility, - airport: controller.callsign.split('_')[0] - }; - }); - - // Separate CTR controllers - const ctrControllers = allControllers.filter(c => c.facilityType === 6); - - // Get non-CTR controllers - const localControllers = allControllers.filter(c => c.facilityType < 6 && c.facilityType > 0 && !c.callsign.includes("ATIS")); - - // Group local controllers by airport - const controllersByAirport = localControllers.reduce((acc: any, controller) => { - if (!acc[controller.airport]) { - acc[controller.airport] = []; - } - acc[controller.airport].push(controller); - return acc; - }, {}); - - // Sort controllers at each airport by facility type (higher number = higher priority) - Object.values(controllersByAirport).forEach((controllers: any) => { - controllers.sort((a: any, b: any) => b.facilityType - a.facilityType); - }); - - // Add CTR coverage for airports without local controllers - ctrControllers.forEach(ctr => { - const fir = ctr.callsign.split('_')[0]; - const coveredAirports = firCoverage[fir] || []; - - coveredAirports.forEach(airport => { - // Only add CTR if no local controllers are available - if (!controllersByAirport[airport] || controllersByAirport[airport].length === 0) { - controllersByAirport[airport] = [{ - callsign: ctr.callsign, - frequency: ctr.frequency, - facility: ctr.facility, - facilityLong: "Centre (Top-down)", - airport: airport - }]; - } - }); - }); - - return NextResponse.json(controllersByAirport); + return NextResponse.json(controllers) } catch (error) { - console.error(error); - return NextResponse.json({ error: 'Failed to fetch controller data' }, { status: 500 }); + console.error('Failed to fetch controller data:', error) + return NextResponse.json({ error: 'Failed to fetch controller data' }, { status: 500 }) } } \ No newline at end of file diff --git a/app/api/cron/update-controllers/route.ts b/app/api/cron/update-controllers/route.ts index 75ed94d..5879893 100644 --- a/app/api/cron/update-controllers/route.ts +++ b/app/api/cron/update-controllers/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@supabase/supabase-js' - import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' const facilityTypes = { 0: "OBS", @@ -12,61 +11,55 @@ const facilityTypes = { 6: "CTR", } as const; -// Define FIR coverage -const CZQM_AIRPORTS = ["CYHZ", "CYFC", "CYQM", "CYSJ", "CYZX", "CYYG", "CYYT", "CYQX", "CYYR", "LFVP", "CYQI", "CYAY", "CYDF", "CYJT"]; +const CZQM_AIRPORTS = ["CYHZ", "CYFC", "CYQM", "CYSJ", "CYZX", "CYYG", "CYYT", "CYQX", "CYYR", "LFVP", "CYQI", "CYAY", "CYDF", "CYJT", "BOS"]; export async function GET() { try { - // Fetch current VATSIM data const response = await fetch('https://data.vatsim.net/v3/vatsim-data.json'); if (!response.ok) throw new Error('Failed to fetch VATSIM data'); const data = await response.json(); - const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_SECRET_SUPABASE_KEY) - // Filter controllers in CZQM airspace const czqmControllers = data.controllers.filter((controller: any) => { const callsign = controller.callsign; - // console.log(callsign) - return callsign.startsWith('CZQM_') || - callsign.startsWith('CZQX_') || + return callsign.startsWith('CZQM_') || CZQM_AIRPORTS.some(airport => callsign.startsWith(airport)); }); - + // Process each controller for (const controller of czqmControllers) { const facilityType = facilityTypes[controller.facility as keyof typeof facilityTypes]; const airport = CZQM_AIRPORTS.find(ap => controller.callsign.startsWith(ap)) || 'CZQM'; - // Insert or update controller session - const { error } = await supabase - .from('controller_sessions') - .upsert({ - cid: controller.cid, + await prisma.controllerSession.upsert({ + where: { + id: parseInt(controller.cid), + }, + create: { + cid: String(controller.cid), name: controller.name, callsign: controller.callsign, - facility_type: facilityType, + facilityType: facilityType, frequency: controller.frequency, airport: airport, - last_seen: new Date().toISOString(), - logon_time: controller.logon_time, - }); - - if (error) { - console.error('Error updating controller session:', error); - } + lastSeen: new Date(), + logonTime: new Date(controller.logon_time), + }, + update: { + lastSeen: new Date(), + }, + }); } - // Clean up old sessions (controllers who have logged off) - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const { error: cleanupError } = await supabase - .from('controller_sessions') - .delete() - .lt('last_seen', fiveMinutesAgo); - - if (cleanupError) { - console.error('Error cleaning up old sessions:', cleanupError); - } + // Clean up old sessions + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + await prisma.controllerSession.deleteMany({ + where: { + lastSeen: { + lt: fiveMinutesAgo, + }, + }, + }); return NextResponse.json({ success: true }); } catch (error) { diff --git a/app/controllers/[cid]/page.tsx b/app/controllers/[cid]/page.tsx index 7117854..954d06d 100644 --- a/app/controllers/[cid]/page.tsx +++ b/app/controllers/[cid]/page.tsx @@ -5,25 +5,19 @@ import { useParams } from "next/navigation"; import { Card } from "@/components/ui/card"; import { Header } from "@/components/header"; import { ControllerProfile } from "@/components/controller-profile"; -import { createClient } from "@/lib/supabase/client"; export default function ControllerProfilePage() { const params = useParams(); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); - const supabase = createClient(); useEffect(() => { async function loadControllerData() { try { - const { data, error } = await supabase - .from('controller_sessions') - .select('*') - .eq('cid', params.cid) - .order('last_seen', { ascending: false }); - - if (error) throw error; - setSessions(data || []); + const response = await fetch(`/api/controllers/${params.cid}`); + if (!response.ok) throw new Error('Failed to fetch controller data'); + const data = await response.json(); + setSessions(data); } catch (error) { console.error("Error fetching controller data:", error); } finally { diff --git a/app/controllers/page.tsx b/app/controllers/page.tsx index 13832c5..9f6abfa 100644 --- a/app/controllers/page.tsx +++ b/app/controllers/page.tsx @@ -4,33 +4,19 @@ import { useEffect, useState } from "react"; import { Card } from "@/components/ui/card"; import { Header } from "@/components/header"; import { ControllerTable } from "@/components/controller-table"; -import { createClient } from "@/lib/supabase/client"; +import { prisma } from '@/lib/prisma' export default function ControllersPage() { const [controllers, setControllers] = useState([]); const [loading, setLoading] = useState(true); - const supabase = createClient(); useEffect(() => { async function loadControllers() { try { - // Get unique controllers with their latest session - const { data, error } = await supabase - .from('controller_sessions') - .select('*') - .order('last_seen', { ascending: false }); + const response = await fetch('/api/controllers'); + if (!response.ok) throw new Error('Failed to fetch METAR data'); + const uniqueControllers = await response.json(); - if (error) throw error; - - // Group by CID and take the most recent session - const uniqueControllers = data.reduce((acc: any[], curr) => { - const existingIndex = acc.findIndex(c => c.cid === curr.cid); - if (existingIndex === -1) { - acc.push(curr); - } - return acc; - }, []); - setControllers(uniqueControllers); } catch (error) { console.error("Error fetching controller data:", error); diff --git a/bun.lockb b/bun.lockb index 070e31b..47bae05 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/controller-table.tsx b/components/controller-table.tsx index 0b7b613..b9c1a53 100644 --- a/components/controller-table.tsx +++ b/components/controller-table.tsx @@ -53,10 +53,10 @@ export function ControllerTable({ data, loading }: ControllerTableProps) { {controller.airport} {controller.frequency} - {formatDistanceToNow(new Date(controller.last_seen), { addSuffix: true })} + {formatDistanceToNow(new Date(controller.lastSeen), { addSuffix: true })} - {formatSessionDuration(controller.logon_time, controller.last_seen)} + {formatSessionDuration(controller.logonTime, controller.lastSeen)} ))} diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..16dfe88 --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +export const prisma = globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma \ No newline at end of file diff --git a/package.json b/package.json index eff36f8..f3c3c98 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,13 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "postinstall": "prisma generate" }, "dependencies": { "@hookform/resolvers": "^3.9.0", "@next/swc-wasm-nodejs": "13.5.1", + "@prisma/client": "^5.10.2", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", @@ -38,8 +40,6 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@supabase/auth-helpers-nextjs": "^0.9.0", - "@supabase/supabase-js": "^2.39.0", "@types/node": "20.6.2", "@types/react": "18.2.22", "@types/react-dom": "18.2.7", @@ -63,12 +63,14 @@ "react-resizable-panels": "^2.1.3", "recharts": "^2.12.7", "sonner": "^1.5.0", - "supabase": "^1.223.10", "tailwind-merge": "^2.5.2", "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.7", "typescript": "5.2.2", "vaul": "^0.9.9", "zod": "^3.23.8" + }, + "devDependencies": { + "prisma": "^5.10.2" } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..99c6c75 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,25 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model ControllerSession { + id Int @id @default(autoincrement()) + cid String + name String + callsign String + facilityType String + frequency String + airport String + lastSeen DateTime @map("last_seen") + logonTime DateTime @map("logon_time") + createdAt DateTime @default(now()) @map("created_at") + + @@map("controller_sessions") + @@index([cid]) + @@index([lastSeen]) +} \ No newline at end of file