update fitur klasifikasi

This commit is contained in:
ramzdhani11 2026-04-15 01:18:54 +07:00
parent 0bdb00f471
commit 70198dde56
9 changed files with 645 additions and 376 deletions

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class TomatController extends Controller
{
protected $apiUrl = 'http://127.0.0.1:5000/predict';
/**
* Show upload form
*/
public function index()
{
return view('tomat.upload');
}
/**
* Handle image upload dan kirim ke Flask API
*/
public function classify(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:16384',
]);
try {
$uploadedFile = $request->file('image');
// Kirim file langsung ke Flask tanpa resize ulang di sini
// Flask sudah handle resize 256x256 sendiri
$response = Http::timeout(30)
->attach(
'image',
file_get_contents($uploadedFile->getRealPath()),
$uploadedFile->getClientOriginalName()
)
->post($this->apiUrl);
if ($response->failed()) {
return back()->with('error', 'Gagal menghubungi API klasifikasi. Status: ' . $response->status());
}
$result = $response->json();
if (!isset($result['success']) || !$result['success']) {
$errorMsg = $result['message'] ?? 'Prediksi gagal';
return back()->with('error', 'Error dari API: ' . $errorMsg);
}
// Simpan hasil ke session
session([
'prediction_result' => $result,
'processed_at' => now(),
'original_size' => $uploadedFile->getSize(),
]);
return redirect()->route('tomat.result');
} catch (\Illuminate\Http\Client\ConnectionException $e) {
return back()->with('error', 'Tidak dapat terhubung ke API Flask. Pastikan server Python sudah berjalan (python app.py).');
} catch (\Exception $e) {
return back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage());
}
}
/**
* Show prediction result
*/
public function getResult()
{
$result = session('prediction_result');
$processedAt = session('processed_at');
if (!$result) {
return redirect()->route('tomat.upload')
->with('error', 'Tidak ada hasil prediksi. Silakan upload gambar terlebih dahulu.');
}
// ✅ FIX: nama view disesuaikan dengan file yang ada
return view('tomat.classification_result', compact('result', 'processedAt'));
}
/**
* Check API service status
* Timeout 3 detik sudah cukup untuk health check lokal
*/
public function checkService()
{
try {
$response = Http::timeout(3)->get('http://127.0.0.1:5000/health');
if ($response->successful()) {
$health = $response->json();
return response()->json([
'success' => true,
'status' => 'online',
'service' => $health['service'] ?? 'Tomat Classification API',
'model_loaded' => $health['model_loaded'] ?? false
]);
}
return response()->json([
'success' => false,
'status' => 'offline',
'message' => 'API Flask tidak tersedia'
], 503);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'status' => 'error',
'message' => 'Tidak dapat terhubung ke API Flask: ' . $e->getMessage()
], 503);
}
}
/**
* Get model info dari Flask API
*/
public function getModelInfo()
{
try {
$response = Http::timeout(5)->get('http://127.0.0.1:5000/info');
if ($response->successful()) {
return response()->json($response->json());
}
return response()->json([
'success' => false,
'message' => 'Gagal mengambil info model'
], 503);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 503);
}
}
}

View File

@ -8,7 +8,8 @@
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
"laravel/tinker": "^2.10.1",
"intervention/image": "^2.7"
},
"require-dev": {
"fakerphp/faker": "^1.23",

View File

@ -1,125 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hasil Klasifikasi - Maturity Scan Tomat</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:300,400,500,600,700&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<!-- Navbar -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-2xl font-bold text-red-600">MaturityScan Tomat</span>
</div>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="/" class="text-gray-900 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Home</a>
<a href="#" class="text-gray-500 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">About</a>
</div>
</div>
<div class="md:hidden">
<button class="text-gray-500 hover:text-gray-700 focus:outline-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="flex-grow">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<!-- Result Card -->
<div class="bg-white rounded-2xl shadow-lg p-8 lg:p-12">
<!-- Title -->
<div class="text-center mb-8">
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900">
Hasil Klasifikasi Tomat
</h1>
</div>
<!-- Image Display -->
@if($imagePath)
<div class="mb-8">
<img src="{{ asset('storage/' . $imagePath) }}"
alt="Tomato Image"
class="w-full h-auto rounded-2xl shadow-md object-cover"
style="max-height: 400px; object-position: center;">
</div>
@endif
<!-- Classification Results -->
<div class="space-y-6">
<!-- Maturity Level -->
<div class="flex items-center justify-between">
<span class="text-lg font-semibold text-gray-700">Kematangan:</span>
<span class="px-4 py-2 rounded-full text-white font-bold text-sm {{ $maturityColor }}">
{{ ucfirst($category) }}
</span>
</div>
<!-- Probability -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-lg font-semibold text-gray-700">Probabilitas:</span>
<span class="text-lg font-bold text-gray-900">{{ $probability }}%</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ease-out {{ $progressColor }}"
style="width: {{ $probability }}%">
</div>
</div>
</div>
<!-- Description -->
<div class="bg-gray-50 rounded-xl p-6">
<p class="text-gray-700 leading-relaxed">
{{ $description }}
</p>
</div>
</div>
<!-- Action Button -->
<div class="mt-8 text-center">
<a href="{{ route('upload') }}"
class="inline-flex items-center bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-8 rounded-lg shadow-lg transition-all duration-200 transform hover:scale-105">
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Unggah Gambar Lagi
</a>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white py-6 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<p class="text-gray-400 text-sm">
© 2026 MaturityScanTomat. All rights reserved.
</p>
</div>
</div>
</footer>
</body>
</html>

View File

@ -1,243 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Unggah Gambar Tomat - Tomato Maturity Scan</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:300,400,500,600,700&display=swap" rel="stylesheet" />
<!-- Tailwind CDN (Tanpa Vite) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom Tailwind Config (Optional) -->
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
}
}
}
};
</script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<!-- Navbar -->
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<span class="text-2xl font-bold text-red-600">MaturityScan Tomat </span>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="/" class="text-gray-900 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium">Beranda</a>
</div>
</div>
<div class="md:hidden">
<button class="text-gray-500 hover:text-gray-700 focus:outline-none">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Main -->
<main class="flex-grow">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="text-center mb-12">
<h1 class="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
Unggah Gambar Tomat Anda
</h1>
<p class="text-lg text-gray-600">
Seret dan lepas gambar tomat Anda atau klik untuk memilih file.
</p>
</div>
<form action="{{ route('upload.store') }}" method="POST" enctype="multipart/form-data" class="max-w-2xl mx-auto">
@csrf
<!-- Error -->
@if ($errors->any())
<div class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<ul class="list-disc list-inside text-red-800">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- Success -->
@if (session('success'))
<div class="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 text-green-800">
{{ session('success') }}
</div>
@endif
<!-- Upload Box -->
<div class="relative">
<div id="dropZone"
class="border-2 border-dashed border-gray-300 rounded-xl p-12 text-center hover:border-red-400 cursor-pointer bg-white">
<input type="file"
id="fileInput"
name="tomato_image"
accept="image/jpeg,image/jpg,image/png"
class="hidden"
required>
<div class="mb-6">
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
</div>
<h3 class="text-xl font-semibold text-gray-700 mb-2">Seret & Lepas Gambar</h3>
<p class="text-gray-500 mb-4">atau klik untuk mengunggah</p>
<p class="text-sm text-gray-400">Format JPG atau PNG</p>
</div>
<!-- Preview -->
<div id="filePreview" class="hidden mt-4">
<div class="bg-white rounded-lg p-4 shadow-sm flex items-center space-x-4">
<img id="previewImage" src="" class="h-20 w-20 object-cover rounded-lg">
<div class="flex-grow">
<p id="fileName" class="text-sm font-medium text-gray-900"></p>
<p id="fileSize" class="text-sm text-gray-500"></p>
</div>
<button type="button" id="removeFile" class="text-red-500 hover:text-red-700">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<p class="mt-8 text-center text-gray-600">
Pastikan gambar jelas dan tidak blur.
</p>
<div class="mt-8 text-center">
<button type="submit" id="submitBtn"
class="bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-8 rounded-lg shadow-lg disabled:bg-gray-400 disabled:cursor-not-allowed"
disabled>
<span id="btnText">Unggah Gambar</span>
<span id="btnLoading" class="hidden">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Mengunggah...
</span>
</button>
</div>
</form>
</div>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white py-6 mt-auto">
<div class="max-w-7xl mx-auto text-center text-gray-400 text-sm">
© 2026 MaturityScanTomat. All rights reserved.
</div>
</footer>
<!-- JS -->
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const filePreview = document.getElementById('filePreview');
const previewImage = document.getElementById('previewImage');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const removeFileBtn = document.getElementById('removeFile');
const submitBtn = document.getElementById('submitBtn');
const btnText = document.getElementById('btnText');
const btnLoading = document.getElementById('btnLoading');
dropZone.onclick = () => fileInput.click();
dropZone.ondragover = e => {
e.preventDefault();
dropZone.classList.add('border-red-400', 'bg-red-50');
};
dropZone.ondragleave = e => {
e.preventDefault();
dropZone.classList.remove('border-red-400', 'bg-red-50');
};
dropZone.ondrop = e => {
e.preventDefault();
dropZone.classList.remove('border-red-400', 'bg-red-50');
handleFile(e.dataTransfer.files[0]);
};
fileInput.onchange = e => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
};
function handleFile(file) {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (!validTypes.includes(file.type)) {
alert('Hanya file JPG dan PNG yang diperbolehkan.');
return resetFile();
}
if (file.size > 5 * 1024 * 1024) {
alert('Ukuran maksimal 5MB.');
return resetFile();
}
const reader = new FileReader();
reader.onload = e => {
previewImage.src = e.target.result;
fileName.textContent = file.name;
fileSize.textContent = (file.size / 1024).toFixed(1) + " KB";
dropZone.classList.add('hidden');
filePreview.classList.remove('hidden');
submitBtn.disabled = false;
}
reader.readAsDataURL(file);
}
removeFileBtn.onclick = () => resetFile();
function resetFile() {
fileInput.value = "";
dropZone.classList.remove('hidden');
filePreview.classList.add('hidden');
submitBtn.disabled = true;
}
document.querySelector("form").onsubmit = () => {
submitBtn.disabled = true;
btnText.classList.add('hidden');
btnLoading.classList.remove('hidden');
};
</script>
</body>
</html>

View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hasil Klasifikasi - Maturity Scan Tomat</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:300,400,500,600,700&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<span class="text-2xl font-bold text-red-600">MaturityScan Tomat</span>
<a href="/" class="text-gray-900 hover:text-red-600 px-3 py-2 text-sm font-medium">Home</a>
</div>
</div>
</nav>
<main class="flex-grow">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@if (session('error'))
<div class="mb-8 bg-red-50 border border-red-200 rounded-lg p-6 flex items-center">
<i class="fas fa-exclamation-triangle text-red-600 mr-3"></i>
<p class="text-red-800 font-semibold">{{ session('error') }}</p>
</div>
@endif
@if(isset($result) && $result['success'])
@php
$predictionClass = $result['prediction']['class'];
$confidence = $result['prediction']['confidence_percentage'];
$probabilities = $result['prediction']['probabilities'];
$metadata = $result['metadata'];
@endphp
<div class="bg-white rounded-2xl shadow-lg p-8 lg:p-12">
<div class="text-center mb-8">
<h1 class="text-3xl lg:text-4xl font-bold text-gray-900">Hasil Klasifikasi Tomat</h1>
</div>
<div class="text-center mb-8">
<div class="mb-6">
@if($predictionClass == 'matang')
<div class="text-6xl mb-4">🍅</div>
<span class="px-6 py-3 rounded-full text-white font-bold text-lg bg-green-500">Matang</span>
@elseif($predictionClass == 'mentah')
<div class="text-6xl mb-4">🟢</div>
<span class="px-6 py-3 rounded-full text-white font-bold text-lg bg-yellow-500">Mentah</span>
@else
<div class="text-6xl mb-4">🟡</div>
<span class="px-6 py-3 rounded-full text-white font-bold text-lg bg-purple-500">Setengah Matang</span>
@endif
</div>
<div class="max-w-sm mx-auto mb-8">
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span class="font-semibold">Tingkat Kepercayaan</span>
<span class="font-bold">{{ number_format($confidence, 1) }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
style="width: {{ $confidence }}%"></div>
</div>
</div>
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-700 mb-4">Probabilitas Setiap Kelas</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@foreach($probabilities as $class => $prob)
<div class="bg-gray-50 rounded-lg p-4 text-center">
<h4 class="font-semibold mb-2
@if($class == 'matang') text-green-600
@elseif($class == 'mentah') text-yellow-600
@else text-purple-600 @endif">
{{ ucfirst(str_replace('_', ' ', $class)) }}
</h4>
<div class="text-2xl font-bold mb-1">{{ number_format($prob['percentage'], 1) }}%</div>
<div class="text-sm text-gray-500">{{ number_format($prob['probability'], 4) }}</div>
</div>
@endforeach
</div>
</div>
<div class="bg-gray-50 rounded-xl p-6 mb-8 text-left">
<h3 class="text-lg font-semibold text-gray-700 mb-4">Informasi Proses</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div><strong>Model:</strong> {{ $metadata['model_type'] ?? 'RandomForest' }}</div>
<div><strong>Waktu Proses:</strong> {{ $metadata['processing_time_seconds'] ?? '-' }} detik</div>
<div><strong>Fitur:</strong> {{ $metadata['features_used'] ?? '-' }}</div>
<div><strong>Diproses:</strong> {{ $processedAt ? $processedAt->format('d M Y, H:i:s') : '-' }}</div>
</div>
</div>
<div class="flex justify-center gap-4 flex-wrap">
<a href="{{ route('tomat.upload') }}"
class="inline-flex items-center bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-6 rounded-lg shadow-lg transition-all">
<i class="fas fa-redo mr-2"></i> Upload Gambar Baru
</a>
<a href="{{ route('tomat.clear') }}"
class="inline-flex items-center bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-6 rounded-lg shadow-lg transition-all">
<i class="fas fa-trash mr-2"></i> Clear Result
</a>
</div>
</div>
</div>
@else
<div class="text-center">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-8">
<i class="fas fa-exclamation-triangle text-yellow-600 text-4xl mb-4"></i>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Tidak Ada Hasil</h3>
<p class="text-gray-600 mb-6">Silakan upload gambar tomat terlebih dahulu.</p>
<a href="{{ route('tomat.upload') }}"
class="inline-flex items-center bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-6 rounded-lg shadow-lg">
<i class="fas fa-upload mr-2"></i> Upload Gambar
</a>
</div>
</div>
@endif
</div>
</main>
<footer class="bg-gray-900 text-white py-6 mt-auto">
<div class="max-w-7xl mx-auto text-center text-gray-400 text-sm">
© 2026 MaturityScanTomat. All rights reserved.
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Klasifikasi Kematangan Tomat</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
.upload-area {
border: 3px dashed #007bff;
border-radius: 10px;
padding: 40px;
text-align: center;
background-color: #f8f9fa;
transition: all 0.3s ease;
cursor: pointer;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-area:hover { border-color: #0056b3; background-color: #e9ecef; }
.upload-area.dragover { border-color: #28a745; background-color: #d4edda; }
.preview-image { max-width: 100%; max-height: 300px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.loading-spinner { display: none; }
.service-status { padding: 10px 15px; border-radius: 5px; margin-bottom: 20px; }
.status-online { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-offline { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.tomat-icon { font-size: 4rem; color: #ff6b6b; margin-bottom: 20px; }
.feature-card { border: none; box-shadow: 0 2px 10px rgba(0,0,0,0.1); transition: transform 0.3s ease; }
.feature-card:hover { transform: translateY(-5px); }
</style>
</head>
<body>
<div class="container py-5">
<header class="text-center mb-5">
<h1 class="display-4 fw-bold text-danger">
<i class="fas fa-lemon me-3"></i>Klasifikasi Kematangan Tomat
</h1>
<p class="lead text-muted">Sistem Klasifikasi Tingkat Kematangan Tomat menggunakan Machine Learning</p>
</header>
<!-- Service Status -->
<div id="serviceStatus" class="service-status text-center">
<i class="fas fa-spinner fa-spin me-2"></i> Memeriksa status layanan...
</div>
<!-- Error/Success dari session -->
@if (session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
@if (session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card feature-card mb-4">
<div class="card-body">
<h3 class="card-title text-center mb-4">
<i class="fas fa-upload me-2"></i>Upload Gambar Tomat
</h3>
{{-- FIX UTAMA: Pakai form biasa (method POST), bukan AJAX fetch --}}
{{-- Controller sudah return redirect(), bukan JSON --}}
<form id="uploadForm"
action="{{ route('tomat.classify') }}"
method="POST"
enctype="multipart/form-data">
@csrf
<!-- Upload Area -->
<div id="uploadArea" class="upload-area mb-4">
<i class="fas fa-cloud-upload-alt tomat-icon"></i>
<h4>Drag & Drop Gambar di sini</h4>
<p class="text-muted">atau klik untuk memilih file</p>
<input type="file" id="imageInput" name="image" accept="image/*" style="display: none;">
<button type="button" class="btn btn-primary" onclick="document.getElementById('imageInput').click()">
<i class="fas fa-folder-open me-2"></i>Pilih File
</button>
<p class="text-muted mt-3">Format: PNG, JPG, JPEG (Max: 16MB)</p>
</div>
<!-- Preview Area -->
<div id="previewArea" class="text-center mb-4" style="display: none;">
<img id="previewImage" class="preview-image mb-3" alt="Preview">
<div class="d-flex justify-content-center gap-2">
{{-- Tombol submit biasa, bukan JavaScript fetch --}}
<button type="submit" id="submitBtn" class="btn btn-success btn-lg">
<i class="fas fa-microscope me-2"></i>Klasifikasi
</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="clearImage()">
<i class="fas fa-trash me-2"></i>Hapus
</button>
</div>
</div>
</form>
<!-- Loading - hanya tampil saat form submit -->
<div id="loadingSpinner" class="loading-spinner text-center">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Sedang memproses gambar...</p>
</div>
</div>
</div>
<!-- Features -->
<div class="row mt-5">
<div class="col-md-4 mb-3">
<div class="card feature-card h-100">
<div class="card-body text-center">
<i class="fas fa-brain fa-3x text-primary mb-3"></i>
<h5>Machine Learning</h5>
<p class="text-muted small">Menggunakan Random Forest untuk klasifikasi akurat</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card feature-card h-100">
<div class="card-body text-center">
<i class="fas fa-palette fa-3x text-success mb-3"></i>
<h5>Color Histogram</h5>
<p class="text-muted small">Ekstraksi fitur RGB 8x8x8 untuk analisis warna</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card feature-card h-100">
<div class="card-body text-center">
<i class="fas fa-tachometer-alt fa-3x text-warning mb-3"></i>
<h5>Real-time</h5>
<p class="text-muted small">Proses klasifikasi cepat dan hasil instan</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let selectedFile = null;
// ✅ FIX: checkServiceStatus hanya dipanggil SEKALI saat load, tidak ada setInterval
document.addEventListener('DOMContentLoaded', function() {
checkServiceStatus();
// HAPUS: setInterval(checkServiceStatus, 30000) — ini penyebab request berulang
});
async function checkServiceStatus() {
try {
const response = await fetch('/tomat/service-status');
const data = await response.json();
const statusDiv = document.getElementById('serviceStatus');
if (data.success && data.status === 'online') {
statusDiv.className = 'service-status status-online';
statusDiv.innerHTML = `
<i class="fas fa-check-circle me-2"></i>
Layanan Aktif - ${data.service || 'Tomat Classification API'}
${data.model_loaded ? ' <i class="fas fa-check-circle ms-2"></i> Model Loaded' : ''}
`;
} else {
statusDiv.className = 'service-status status-offline';
statusDiv.innerHTML = `<i class="fas fa-times-circle me-2"></i> Layanan Tidak Tersedia`;
}
} catch (error) {
const statusDiv = document.getElementById('serviceStatus');
statusDiv.className = 'service-status status-offline';
statusDiv.innerHTML = `<i class="fas fa-times-circle me-2"></i> Tidak dapat terhubung ke API`;
}
}
// File input
document.getElementById('imageInput').addEventListener('change', function(e) {
if (e.target.files[0]) handleFileSelect(e.target.files[0]);
});
// Drag and drop
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', e => { e.preventDefault(); uploadArea.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) handleFileSelect(e.dataTransfer.files[0]);
});
function handleFileSelect(file) {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
if (!validTypes.includes(file.type)) {
alert('File tidak valid. Harap upload gambar (PNG, JPG, JPEG)');
return;
}
if (file.size > 16 * 1024 * 1024) {
alert('Ukuran file terlalu besar. Maksimal 16MB');
return;
}
selectedFile = file;
// Set file ke input agar ikut terkirim saat form submit
const dt = new DataTransfer();
dt.items.add(file);
document.getElementById('imageInput').files = dt.files;
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('previewImage').src = e.target.result;
document.getElementById('uploadArea').style.display = 'none';
document.getElementById('previewArea').style.display = 'block';
};
reader.readAsDataURL(file);
}
function clearImage() {
selectedFile = null;
document.getElementById('imageInput').value = '';
document.getElementById('uploadArea').style.display = 'flex';
document.getElementById('previewArea').style.display = 'none';
document.getElementById('previewImage').src = '';
}
// ✅ FIX: Saat submit, tampilkan loading dan biarkan form submit normal (bukan fetch)
document.getElementById('uploadForm').addEventListener('submit', function() {
document.getElementById('loadingSpinner').style.display = 'block';
document.getElementById('previewArea').style.display = 'none';
document.getElementById('submitBtn').disabled = true;
});
</script>
</body>
</html>

View File

@ -28,7 +28,7 @@
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="#" class="text-gray-900 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Beranda</a>
<a href="{{ route('about') }}"class="text-gray-500 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Tentang kami</a>
<a href="{{ route('about') }}" class="text-gray-500 hover:text-red-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">Tentang kami</a>
<a href="{{ route('login') }}" class="bg-red-600 hover:bg-red-700 text-white font-semibold py-2 px-6 rounded-lg transition-colors">
Login
</a>
@ -57,7 +57,7 @@
MaturityScan Tomat adalah aplikasi berbasis website yang membantu Anda menentukan tingkat kematangan tomat secara otomatis menggunakan analisis citra digital. Sistem memanfaatkan metode Color Histogram dan algoritma Random Forest untuk memberikan hasil klasifikasi yang cepat dan objektif.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<a href="{{ route('upload.index') }}" class="bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-8 rounded-lg shadow-lg transform hover:scale-105 transition-all duration-200 inline-block text-center">
<a href="{{ route('tomat.upload') }}" class="bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-8 rounded-lg shadow-lg transform hover:scale-105 transition-all duration-200 inline-block text-center">
Mulai Klasifikasi
</a>
<a href="{{ route('about') }}" class="bg-white hover:bg-gray-50 text-gray-700 font-semibold py-3 px-8 rounded-lg shadow-md border border-gray-200 transition-all duration-200 inline-block text-center">

View File

@ -0,0 +1,97 @@
## GitHub Copilot Chat
- Extension: 0.43.0 (prod)
- VS Code: 1.115.0 (41dd792b5e652393e7787322889ed5fdc58bd75b)
- OS: win32 10.0.19045 x64
- GitHub Account: ramzdhani11
## Network
User Settings:
```json
"http.systemCertificatesNode": true,
"github.copilot.advanced.debug.useElectronFetcher": true,
"github.copilot.advanced.debug.useNodeFetcher": false,x
"github.copilot.advanced.debug.useNodeFetchFetcher": true
```
Connecting to https://api.github.com:
- DNS ipv4 Lookup: Error (35 ms): getaddrinfo ENOENT api.github.com
- DNS ipv6 Lookup: Error (3 ms): getaddrinfo ENOTFOUND api.github.com
- Proxy URL: None (2 ms)
- Electron fetch (configured): Error (2 ms): Error: net::ERR_ADDRESS_UNREACHABLE
at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/utility_init:2:10684)
at SimpleURLLoaderWrapper.emit (node:events:519:28)
{"is_request_error":true,"network_process_crashed":false}
- Node.js https: Error (29 ms): Error: getaddrinfo ENOTFOUND api.github.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
- Node.js fetch: Error (36 ms): TypeError: fetch failed
at node:internal/deps/undici/undici:14902:13
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async t._fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:5228)
at async t.fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:4540)
at async u (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5325:186)
at async Sg._executeContributedCommand (file:///c:/Users/User/AppData/Local/Programs/Microsoft%20VS%20Code/41dd792b5e/resources/app/out/vs/workbench/api/node/extensionHostProcess.js:501:48675)
Error: getaddrinfo ENOTFOUND api.github.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Connecting to https://api.githubcopilot.com/_ping:
- DNS ipv4 Lookup: Error (25 ms): getaddrinfo ENOENT api.githubcopilot.com
- DNS ipv6 Lookup: Error (24 ms): getaddrinfo ENOTFOUND api.githubcopilot.com
- Proxy URL: None (48 ms)
- Electron fetch (configured): Error (5 ms): Error: net::ERR_ADDRESS_UNREACHABLE
at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/utility_init:2:10684)
at SimpleURLLoaderWrapper.emit (node:events:519:28)
{"is_request_error":true,"network_process_crashed":false}
- Node.js https: Error (85 ms): Error: getaddrinfo ENOTFOUND api.githubcopilot.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
- Node.js fetch: Error (101 ms): TypeError: fetch failed
at node:internal/deps/undici/undici:14902:13
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async t._fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:5228)
at async t.fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:4540)
at async u (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5325:186)
at async Sg._executeContributedCommand (file:///c:/Users/User/AppData/Local/Programs/Microsoft%20VS%20Code/41dd792b5e/resources/app/out/vs/workbench/api/node/extensionHostProcess.js:501:48675)
Error: getaddrinfo ENOTFOUND api.githubcopilot.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Connecting to https://copilot-proxy.githubusercontent.com/_ping:
- DNS ipv4 Lookup: Error (23 ms): getaddrinfo ENOENT copilot-proxy.githubusercontent.com
- DNS ipv6 Lookup: Error (21 ms): getaddrinfo ENOTFOUND copilot-proxy.githubusercontent.com
- Proxy URL: None (4 ms)
- Electron fetch (configured): Error (16 ms): Error: net::ERR_ADDRESS_UNREACHABLE
at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/utility_init:2:10684)
at SimpleURLLoaderWrapper.emit (node:events:519:28)
{"is_request_error":true,"network_process_crashed":false}
- Node.js https: Error (33 ms): Error: getaddrinfo ENOTFOUND copilot-proxy.githubusercontent.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
- Node.js fetch: Error (46 ms): TypeError: fetch failed
at node:internal/deps/undici/undici:14902:13
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async t._fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:5228)
at async t.fetch (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5293:4540)
at async u (c:\Users\User\.vscode\extensions\github.copilot-chat-0.43.0\dist\extension.js:5325:186)
at async Sg._executeContributedCommand (file:///c:/Users/User/AppData/Local/Programs/Microsoft%20VS%20Code/41dd792b5e/resources/app/out/vs/workbench/api/node/extensionHostProcess.js:501:48675)
Error: getaddrinfo ENOTFOUND copilot-proxy.githubusercontent.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Connecting to https://mobile.events.data.microsoft.com: Error (3 ms): Error: net::ERR_ADDRESS_UNREACHABLE
at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/utility_init:2:10684)
at SimpleURLLoaderWrapper.emit (node:events:519:28)
{"is_request_error":true,"network_process_crashed":false}
Connecting to https://dc.services.visualstudio.com: Error (35 ms): Error: net::ERR_ADDRESS_UNREACHABLE
at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/utility_init:2:10684)
at SimpleURLLoaderWrapper.emit (node:events:519:28)
{"is_request_error":true,"network_process_crashed":false}
Connecting to https://copilot-telemetry.githubusercontent.com/_ping: Error (47 ms): Error: getaddrinfo ENOTFOUND copilot-telemetry.githubusercontent.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Connecting to https://copilot-telemetry.githubusercontent.com/_ping: Error (47 ms): Error: getaddrinfo ENOTFOUND copilot-telemetry.githubusercontent.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Connecting to https://default.exp-tas.com: Error (69 ms): Error: getaddrinfo ENOTFOUND default.exp-tas.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26)
Number of system certificates: 87
## Documentation
In corporate networks: [Troubleshooting firewall settings for GitHub Copilot](https://docs.github.com/en/copilot/troubleshooting-github-copilot/troubleshooting-firewall-settings-for-github-copilot).

View File

@ -3,6 +3,8 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UploadController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\TomatoController;
use App\Http\Controllers\TomatController;
Route::get('/', function () {
return view('welcome');
@ -13,10 +15,26 @@
return view('landing_page.about');
})->name('about');
// Upload routes
Route::get('/upload', [UploadController::class, 'index'])->name('upload.index');
Route::post('/upload', [UploadController::class, 'store'])->name('upload.store');
Route::get('/upload/result', [UploadController::class, 'result'])->name('upload.result');
// Tomat classification routes
Route::prefix('tomat')->name('tomat.')->group(function () {
Route::get('/', [TomatController::class, 'index'])->name('upload');
Route::get('/upload', [TomatController::class, 'index'])->name('upload');
Route::post('/classify', [TomatController::class, 'classify'])->name('classify');
Route::get('/result', [TomatController::class, 'getResult'])->name('result');
Route::get('/service-status', [TomatController::class, 'checkService'])->name('service-status');
Route::get('/model-info', [TomatController::class, 'getModelInfo'])->name('model-info');
Route::get('/clear', [TomatoController::class, 'clear'])->name('clear');
});
// Legacy upload routes - redirect to new tomat routes
Route::get('/upload', function() {
return redirect()->route('tomat.upload');
})->name('upload.index');
Route::post('/upload', [TomatoController::class, 'upload'])->name('upload.store');
Route::get('/upload/result', function() {
return redirect()->route('tomat.result');
})->name('upload.result');
// Login route (redirect to admin login)
Route::get('/login', function () {
@ -62,7 +80,6 @@
return view('Admin.system-statistics');
})->name('admin.system-statistics');
Route::get('/admin/logout', function () {
// Clear admin session
session()->forget(['admin_logged_in', 'admin_user_id', 'admin_name']);
@ -70,3 +87,9 @@
// Redirect to login with success message
return redirect()->route('admin.login')->with('success', 'Anda telah berhasil logout.');
})->name('admin.logout');
Route::get('/upload', function () {
return view('upload');
});
Route::post('/upload', [TomatoController::class, 'upload'])->name('upload');