Dagens økt

This commit is contained in:
Erol 2026-03-05 05:18:03 +01:00
parent 1ca1868e11
commit 78e7d2b12e
10 changed files with 564 additions and 47 deletions

View file

@ -1,5 +1,5 @@
"""
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
TEE OFF BACKEND API v3.6.9 - KOBLET ADMIN KJØR-KNAPP
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
@ -9,7 +9,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncpg
@ -21,6 +21,11 @@ from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER
from pydantic import BaseModel
from typing import Optional, List
import subprocess
load_dotenv()
# --- KONFIGURASJON ---
@ -28,9 +33,19 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
ALGORITHM = "HS256"
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# --- PYDANTIC MODELLER ---
class ScrapeSettingsUpdate(BaseModel):
scrape_method: Optional[str] = None
scrape_status_url: Optional[str] = None
scrape_status_selector: Optional[str] = None
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
class ScrapeRunRequest(BaseModel):
facility_ids: List[int]
# --- FUNKSJONER ---
def format_row(row):
"""
Vasker data fra databasen:
@ -43,12 +58,10 @@ def format_row(row):
d = dict(row)
# 1. Håndter dato- og tidsformater for JSON-serialisering
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
# 2. Definer alle felter som inneholder JSON-data
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
@ -57,7 +70,6 @@ def format_row(row):
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
]
# Vask list-felter
for field in json_list_fields:
if field in d:
val = d[field]
@ -71,7 +83,6 @@ def format_row(row):
elif not isinstance(val, list):
d[field] = []
# Vask objekt-felter
for field in json_dict_fields:
if field in d:
val = d[field]
@ -87,6 +98,32 @@ def format_row(row):
return d
# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN ---
def run_scrape_worker(facility_ids: List[int]):
"""
Kjører selve skraping-scriptet i bakgrunnen.
Slik kan frontenden et umiddelbart svar, mens skraperen jobber.
"""
print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}")
# Her kjører vi skraping-scriptet ditt via et system-kall (subprocess)
# Dette er den tryggeste måten å starte et annet script på uten å forstyrre API-et.
try:
# Konverterer listen med IDer til en streng som vi kan sende som argument
ids_arg = ",".join(map(str, facility_ids))
# Vi antar at scrape_status.py ligger i samme mappe som main.py
# Slett /dev/null hvis du vil ha logg-utskrifter i terminalen.
command = f"python scrape_status.py --ids {ids_arg} > /dev/null 2>&1"
subprocess.run(command, shell=True, check=True)
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")
except subprocess.CalledProcessError as e:
print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}")
except Exception as e:
print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
@ -106,7 +143,7 @@ async def lifespan(app: FastAPI):
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan)
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
@ -141,14 +178,12 @@ async def login(data: dict):
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
# FIKS: Vi pakker KUN selve verify-sjekken inn i try/except
try:
is_valid = pwd_context.verify(data.get('password'), h)
except Exception as e:
print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
# FIKS: 401 kastes nå UTENFOR try-blokken, slik at vi unngår 500-krasj
if not is_valid:
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
@ -238,6 +273,56 @@ async def get_facility(slug: str):
return format_row(row)
# --- ADMIN ENDPOINTS ---
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
async with app.state.pool.acquire() as conn:
try:
# Sjekk først at anlegget eksisterer
facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id)
if not facility:
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
# Oppdater verdiene i databasen
await conn.execute("""
UPDATE facilities
SET scrape_method = $1,
scrape_status_url = $2,
scrape_status_selector = $3
WHERE id = $4
""",
settings.scrape_method,
settings.scrape_status_url,
settings.scrape_status_selector,
facility_id)
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER ---
@app.post("/api/admin/run-scraper")
async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks):
"""
Tar imot IDer for skraping, og starter en bakgrunnsjobb.
Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente.
"""
if not request.facility_ids:
raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.")
print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}")
# Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks
background_tasks.add_task(run_scrape_worker, request.facility_ids)
return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kø."}
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever."""

View file

@ -11,4 +11,4 @@ python-dotenv
python-jose[cryptography]
passlib[bcrypt]
pyotp
google-generativeai
google-genai

View file

@ -3,6 +3,7 @@ import os
import asyncpg
import smtplib
import re
import argparse
from datetime import datetime
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
@ -12,12 +13,77 @@ try:
except ImportError:
from playwright_stealth import stealth as apply_stealth
from google import genai
from dotenv import load_dotenv
load_dotenv()
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
# ==========================================
# KONFIGURERER GEMINI AI (NY SDK)
# ==========================================
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
client = genai.Client()
async def ask_llm_status(text, course_name, is_single_course):
"""Sender teksten til Gemini og ber om ett enkelt status-ord tilbake."""
# 1. Dynamisk instruks basert på antall baner
if is_single_course:
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
else:
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
# 2. Selve promptet
prompt = f"""
Du er en ekspert å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
Svar KUN med nøyaktig ETT av disse ordene:
- aapen (hvis banen er åpen/sommergreener)
- stengt (hvis banen er lukket/stengt/frost/snø)
- aapen_med_vintergreener (hvis det spilles vintergreener)
- aapner_snart (hvis den åpner om kort tid)
- stenger_snart (hvis den stenger for sesongen om kort tid)
- under_utvikling (hvis den er under utvikling)
- nedlagt (hvis den er nedlagt)
- ukjent (hvis du ikke finner noe info om banen i teksten)
Tekst fra nettsiden:
{text[:15000]}
"""
try:
response = await client.aio.models.generate_content(
model='gemini-2.5-flash',
contents=prompt
)
svar = response.text.strip().lower()
# 3. Sikkerhetsfilteret som matcher ordene i promptet
gyldige_svar = [
"aapen",
"stengt",
"aapen_med_vintergreener",
"aapner_snart",
"stenger_snart",
"under_utvikling",
"nedlagt",
"ukjent"
]
for gyldig in gyldige_svar:
if gyldig in svar:
return gyldig
return "ukjent"
except Exception as e:
print(f"❌ Gemini Feil: {e}")
return "ukjent"
# ==========================================
# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING
# ==========================================
def clean_text(text):
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower()
@ -48,6 +114,7 @@ def interpret_status(text, keyword=None):
def send_report(changes, warnings, successes):
if not changes and not warnings and not successes: return
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n"
@ -67,11 +134,33 @@ def send_report(changes, warnings, successes):
except Exception as e:
print(f"❌ E-post feil: {e}")
async def run_daily_scraping():
# ==========================================
# HOVEDMOTOR
# ==========================================
async def run_daily_scraping(facility_ids=None):
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL")
# --- NYTT: Filtrerer basert på valgte IDer fra Admin-panelet ---
if facility_ids:
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
facility_ids
)
else:
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL"
)
if not facilities:
print("⚠️ Fant ingen anlegg å skrape.")
await conn.close()
return
# ----------------------------------------------------------------
changes, warnings, successes = [], [], []
async with async_playwright() as p:
@ -84,8 +173,7 @@ async def run_daily_scraping():
except: pass
try:
print(f"🔍 Besøker {f['name']}...")
# Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout
print(f"🔍 Besøker {f['name']} (Metode: {f.get('scrape_method') or 'css_selector'})...")
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden
@ -108,26 +196,20 @@ async def run_daily_scraping():
full_text = await element.inner_text()
elif method == 'click_then_css':
# Vi forventer formatet: "knappe_selector||tekst_selector"
parts = f['scrape_status_selector'].split('||')
if len(parts) != 2:
warnings.append(f"{f['name']}: Ugyldig selector for click_then_css (mangler ||)")
continue
btn_selector, text_selector = parts
# 1. Finn og klikk på knappen
btn = page.locator(btn_selector).first
if await btn.count() == 0:
warnings.append(f"{f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
continue
await btn.click()
# 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig
await asyncio.sleep(2)
# 3. Les av teksten
element = page.locator(text_selector).first
if await element.count() == 0:
warnings.append(f"{f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk")
@ -135,6 +217,32 @@ async def run_daily_scraping():
full_text = await element.inner_text()
# NY METODE: LLM PARSE (GEMINI)
elif method == 'llm_parse':
# --- AUTO-KLIKKER ---
print(" 🖱️ Leter etter 'banestatus'-knapper å klikke på...")
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
for knapp in knapper:
try:
if await knapp.is_visible():
await knapp.click(timeout=3000)
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
await asyncio.sleep(2)
break
except Exception:
pass
# --------------------
# Kopierer all synlig tekst fra hele nettsiden
element = page.locator("body").first
if await element.count() == 0:
warnings.append(f"{f['name']}: Klarte ikke å lese siden for AI-tolkning")
continue
råtekst = await element.inner_text()
# Fjerner overflødige linjeskift for å komprimere teksten før sending til Gemini
full_text = " ".join(råtekst.split())
else:
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
continue
@ -142,11 +250,21 @@ async def run_daily_scraping():
await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id'])
courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id'])
# Sjekk om anlegget kun har én bane
is_single_course = len(courses) == 1
for c in courses:
new_status = interpret_status(full_text, c['scrape_keyword'])
# HENTER STATUS VIA AI ELLER GAMMEL METODE
if method == 'llm_parse':
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
new_status = await ask_llm_status(full_text, c['name'], is_single_course)
else:
new_status = interpret_status(full_text, c['scrape_keyword'])
if new_status == "NOT_FOUND":
warnings.append(f"{f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten på siden.")
warnings.append(f"{f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.")
continue
old_status = c['status'] or "ukjent"
@ -159,11 +277,11 @@ async def run_daily_scraping():
print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
except Exception as e:
# Trekker ut kun første linje av feilmeldingen for å unngå massiv og stygg tekst i e-posten
err_msg = str(e).split('\n')[0]
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
finally:
await page.close()
await browser.close()
await conn.close()
@ -171,4 +289,17 @@ async def run_daily_scraping():
print("🏁 Ferdig.")
if __name__ == "__main__":
asyncio.run(run_daily_scraping())
# --- NYTT: Tar imot argumenter fra main.py (Background Task) ---
parser = argparse.ArgumentParser(description="TeeOff Status Scraper")
parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None)
args = parser.parse_args()
facility_ids_list = None
if args.ids:
try:
facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()]
except ValueError:
print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12")
exit(1)
asyncio.run(run_daily_scraping(facility_ids_list))

116
backend/test_gemini.py Normal file
View file

@ -0,0 +1,116 @@
import asyncio
import os
import re
from playwright.async_api import async_playwright
from google import genai
from dotenv import load_dotenv
load_dotenv()
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
client = genai.Client()
async def ask_llm_status(text, course_name, is_single_course):
if is_single_course:
bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane."
else:
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
prompt = f"""
Du er en ekspert å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
Svar KUN med nøyaktig ETT av disse ordene:
- aapen (hvis banen er åpen/sommergreener)
- stengt (hvis banen er lukket/stengt/frost/snø)
- aapen_med_vintergreener (hvis det spilles vintergreener)
- aapner_snart (hvis den åpner om kort tid)
- stenger_snart (hvis den stenger for sesongen om kort tid)
- under_utvikling (hvis den er under utvikling)
- nedlagt (hvis den er nedlagt)
- ukjent (hvis du ikke finner noe info om banen i teksten)
Tekst fra nettsiden:
{text[:15000]}
"""
try:
# Ny måte å kalle modellen asynkront på med google-genai
response = await client.aio.models.generate_content(
model='gemini-2.5-flash',
contents=prompt
)
svar = response.text.strip().lower()
gyldige_svar = [
"aapen", "stengt", "aapen_med_vintergreener",
"aapner_snart", "stenger_snart", "under_utvikling",
"nedlagt", "ukjent"
]
for gyldig in gyldige_svar:
if gyldig in svar:
return gyldig
return "ukjent"
except Exception as e:
print(f"❌ Gemini Feil: {e}")
return "ukjent"
async def run_test():
print("\n" + "="*50)
print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)")
print("="*50)
url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip()
if not url.startswith("http"):
url = "https://" + url
course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip()
is_single = len(course_name) == 0
print("\n⏳ 1. Starter nettleser og besøker siden...")
full_text = ""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
try:
await page.goto(url, timeout=30000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Vent på animasjoner og iframes
# --- NY LOGIKK: AUTO-KLIKKER ---
print("🖱️ Leter etter 'banestatus'-knapper å klikke på...")
# Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver)
knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all()
for knapp in knapper:
try:
if await knapp.is_visible():
await knapp.click(timeout=3000)
print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...")
await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg
break # Vi trenger bare å klikke på den første vi finner
except Exception as e:
# Ignorerer hvis knappen ikke er klikkbar, prøver neste
pass
# --------------------------------
element = page.locator("body").first
råtekst = await element.inner_text()
full_text = " ".join(råtekst.split())
print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.")
except Exception as e:
print(f"❌ Feil ved innlasting av side: {e}")
await browser.close()
return
await browser.close()
print("🧠 2. Sender teksten til Gemini for analyse...")
status = await ask_llm_status(full_text, course_name, is_single)
print("\n" + "="*50)
print(f"🎯 GEMINI SITT SVAR: {status.upper()}")
print("="*50 + "\n")
if __name__ == "__main__":
asyncio.run(run_test())

View file

@ -19,8 +19,7 @@ services:
- "8001:8000"
volumes:
- ./backend:/app
# Denne linjen sørger for at bilder lastet ned av import_wp.py
# lagres direkte i frontendens public-mappe på serveren din:
# Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe:
- ./frontend/public/media:/app/public/media
depends_on:
- db
@ -29,10 +28,11 @@ services:
frontend:
build: ./frontend
container_name: teeoff_frontend
# NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting
command: sh -c "npm run build && npm start"
ports:
- "3000:3000"
volumes:
- ./frontend:/app
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
depends_on:
- api
restart: unless-stopped

View file

@ -9,6 +9,5 @@ RUN npm install
# Kopier resten av koden
COPY . .
# Vi starter serveren i "dev"-modus (utviklingsmodus).
# Dette gjør at vi kan se endringer live mens vi koder!
CMD ["npm", "run", "dev"]
# Vi starter IKKE serveren i "dev"-modus (utviklingsmodus).
CMD ["sh", "-c", "npm run build && npm start"]

View file

@ -1,20 +1,26 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.1
* TEE OFF ADMIN DASHBOARD v1.4 - LIVE PROGRESSION
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Monitorering av banestatus og administrasjon.
* FUNKSJON: Starter bakgrunnsjobber og oppdaterer tabellen live.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
export default function AdminDashboard() {
const [facilities, setFacilities] = useState([]);
const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
// NYTT: Holder styr på om en skraping pågår akkurat nå
const [isScraping, setIsScraping] = useState(false);
useEffect(() => {
// Henter data fra databasen
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
.then(data => {
@ -22,13 +28,69 @@ export default function AdminDashboard() {
setLoading(false);
})
.catch(() => setLoading(false));
};
// Hent data ved første innlasting
useEffect(() => {
fetchFacilities();
}, []);
// NYTT: Hvis skraping pågår, oppdater tabellen hvert 10. sekund!
useEffect(() => {
let interval: NodeJS.Timeout;
if (isScraping) {
interval = setInterval(() => {
fetchFacilities();
}, 10000);
}
return () => clearInterval(interval);
}, [isScraping]);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedFacilities(facilities.map(f => f.id));
} else {
setSelectedFacilities([]);
}
};
const handleSelectOne = (id: number, checked: boolean) => {
if (checked) {
setSelectedFacilities([...selectedFacilities, id]);
} else {
setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id));
}
};
// NYTT: Sender IDene til API-et og starter auto-oppdatering
const handleRunScrapers = async () => {
setIsScraping(true);
try {
const response = await fetch(`${API_URL}/admin/run-scraper`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
if (!response.ok) throw new Error("Kunne ikke starte skraping");
// Valgfritt: Fjern avhukingene når jobben har startet
setSelectedFacilities([]);
// Stopper auto-oppdateringen etter 10 minutter (for sikkerhets skyld)
setTimeout(() => setIsScraping(false), 10 * 60 * 1000);
} catch (error) {
console.error(error);
alert("Feil ved start av skraperen.");
setIsScraping(false);
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
{/* SIDEBAR (22%) */}
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
@ -41,7 +103,6 @@ export default function AdminDashboard() {
</div>
</aside>
{/* HOVEDINNHOLD (78%) */}
<main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12">
@ -49,33 +110,87 @@ export default function AdminDashboard() {
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status {facilities.length} anlegg</p>
</div>
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
{/* NYTT: Knappen endrer utseende når skraping pågår */}
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isScraping}
className={`text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all
${isScraping
? 'bg-yellow-500 animate-pulse cursor-wait'
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
}`}
>
{isScraping ? '🤖 Skraper... Henter live data' : `Kjør valgte skrapere (${selectedFacilities.length})`}
</button>
</header>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6">Anlegg</th>
<th className="pb-6 pl-6 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === facilities.length && facilities.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className="pb-6 pr-10">Anlegg</th>
<th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Metode</th>
<th className="pb-6">Siste Sjekk</th>
<th className="pb-6 text-right">Status</th>
<th className="pb-6">Banestatus</th>
<th className="pb-6 text-right">Handling</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8">
<td className="py-8 pl-6 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.includes(f.id)}
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
</td>
<td className="py-8 pl-10">
<div className="font-black text-lg">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
</td>
<td className="py-8 pr-4">
<ScrapeMethodSelect facility={f} />
</td>
<td className="py-8 text-gray-400 font-mono text-xs">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-8">
<div className="flex flex-col gap-2">
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700";
if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700";
return (
<div key={idx} className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-widest text-gray-400 truncate max-w-[100px]" title={cs.name}>
{cs.name}
</span>
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest ${badgeColor}`}>
{cs.status || 'UKJENT'}
</span>
</div>
)
})}
</div>
</td>
<td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
</td>

View file

@ -0,0 +1,71 @@
"use client";
import { useState } from 'react';
// Tilpass interface til de dataene du allerede har i frontend
interface Facility {
id: number;
scrape_method?: string;
scrape_status_url?: string;
scrape_status_selector?: string;
}
export default function ScrapeMethodSelect({ facility }: { facility: Facility }) {
// Setter standardverdi til 'css_selector' hvis den er tom i databasen
const [method, setMethod] = useState(facility.scrape_method || 'css_selector');
const [isLoading, setIsLoading] = useState(false);
const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback
const handleMethodChange = async (newMethod: string) => {
setMethod(newMethod);
setIsLoading(true);
setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer
try {
// Husk å endre URL-en hvis API-et ditt ligger på et annet domene
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
// Hvis du bruker JWT i headers i stedet for cookies, legg det til her:
// 'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
scrape_method: newMethod,
scrape_status_url: facility.scrape_status_url, // Beholder eksisterende
scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende
})
});
if (!response.ok) {
throw new Error('Feil ved lagring');
}
// Suksess! Lyser grønt et kort sekund
setStatusColor('bg-green-300');
setTimeout(() => setStatusColor('bg-transparent'), 2000);
} catch (error) {
console.error(error);
setStatusColor('bg-red-300'); // Lyser rødt ved feil
alert("Kunne ikke oppdatere skrapemetode.");
} finally {
setIsLoading(false);
}
};
return (
<select
value={method}
onChange={(e) => handleMethodChange(e.target.value)}
disabled={isLoading}
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
>
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse"> Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="">Ingen (Avslått)</option>
</select>
);
}

View file

@ -1,13 +1,13 @@
/**
* TEE OFF SECURITY MIDDLEWARE v1.0
* TEE OFF SECURITY MIDDLEWARE v1.1
* ---------------------------------------------------------------------------
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
* RETTING: Flyttet NextRequest til next/server for å fikse build-error.
* ---------------------------------------------------------------------------
*/
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/request';
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;