583 lines
17 KiB
PHP
583 lines
17 KiB
PHP
<?php
|
|
require_once 'functions/auth.php';
|
|
|
|
if(!isLoggedIn()) {
|
|
header("Location: login.php");
|
|
exit;
|
|
}
|
|
|
|
if (!defined('NODERED_URL')) {
|
|
define('NODERED_URL', 'https://node-red.aquamonn.online');
|
|
}
|
|
|
|
require_once 'partials/header.php';
|
|
$noderedUrl = rtrim(NODERED_URL, '/');
|
|
?>
|
|
|
|
<div class="dashboard-layout">
|
|
<?php require_once 'partials/sidebar.php'; ?>
|
|
|
|
<div class="main-content page-enter">
|
|
|
|
<style>
|
|
.sensor-bar-wrap {
|
|
width: 100%;
|
|
height: 5px;
|
|
background: rgba(255,255,255,.15);
|
|
border-radius: 99px;
|
|
margin-top: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.sensor-bar-fill {
|
|
height: 100%;
|
|
border-radius: 99px;
|
|
background: #22c55e;
|
|
width: 0%;
|
|
transition: width .6s ease, background .3s;
|
|
}
|
|
#conn-status {
|
|
display: inline-block;
|
|
width: 9px; height: 9px;
|
|
border-radius: 50%;
|
|
background: #6b7280;
|
|
margin-right: 6px;
|
|
transition: background .3s;
|
|
}
|
|
#conn-status.online { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
|
#conn-status.offline { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
|
|
#last-update { font-size: 11px; color: rgba(255,255,255,.55); margin-left: 6px; }
|
|
.device-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 3px 10px;
|
|
border-radius: 99px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
background: rgba(255,255,255,.1);
|
|
color: rgba(255,255,255,.5);
|
|
transition: background .3s, color .3s;
|
|
}
|
|
.device-badge.on { background: rgba(34,197,94,.2); color: #22c55e; }
|
|
.device-badge.off { background: rgba(239,68,68,.15); color: #ef4444; }
|
|
</style>
|
|
|
|
<div class="dashboard-layout">
|
|
<?php require_once 'partials/sidebar.php'; ?>
|
|
|
|
<div class="main-content page-enter">
|
|
|
|
<!-- TOPBAR -->
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; flex-wrap:wrap; gap:10px;">
|
|
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
|
<div style="display:flex; align-items:center; color:rgba(25, 22, 22, 0.7); font-size:13px;">
|
|
<span id="conn-status"></span>
|
|
<span id="conn-label">Menghubungkan…</span>
|
|
<span id="last-update"></span>
|
|
</div>
|
|
<span class="device-badge" id="badge-relay">
|
|
|
|
</span>
|
|
<span class="device-badge" id="badge-lampu">
|
|
|
|
</span>
|
|
</div>
|
|
<div class="topbar-icons">
|
|
<div class="notif" id="notifBtn">
|
|
<i class="fas fa-bell"></i>
|
|
<span class="notif-badge">0</span>
|
|
<div class="notif-dropdown" id="notifDropdown">
|
|
<p><b>Notifikasi</b></p>
|
|
<ul><li>Memuat data…</li></ul>
|
|
</div>
|
|
</div>
|
|
<div class="user" id="userBtn">
|
|
<i class="fas fa-user"></i>
|
|
<div class="user-dropdown" id="userDropdown">
|
|
<p><?= htmlspecialchars($_SESSION['email'] ?? 'User') ?></p>
|
|
<a href="logout.php">Logout</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- GRID -->
|
|
<div class="ctrl-main-grid">
|
|
|
|
<!-- BOX SISTEM OTOMATIS -->
|
|
<div class="ctrl-box">
|
|
<div class="ctrl-box-title">Sistem Otomatis</div>
|
|
|
|
<!-- AMONIA -->
|
|
<div class="ctrl-item">
|
|
<span>Batas Amonia</span>
|
|
<input type="number" id="input-amonia" step="0.1">
|
|
<button class="btn-mulai" onclick="setAmonia()">Simpan</button>
|
|
</div>
|
|
<!-- KEKERUHAN -->
|
|
<div class="ctrl-item">
|
|
<span>Batas Kekeruhan</span>
|
|
<input type="number" id="input-kekeruhan" step="0.1" >
|
|
<button class="btn-mulai" onclick="setBatasKekeruhan()">Simpan</button>
|
|
</div>
|
|
<!-- TINGGI AIR -->
|
|
<div class="ctrl-item">
|
|
<span>Batas Tinggi Air</span>
|
|
<input type="number" id="input-tinggi-air" step="1">
|
|
<button class="btn-mulai" onclick="setBatasTinggiAir()">Simpan</button>
|
|
</div>
|
|
<!-- KONTROL TINGGI AQUARIUM -->
|
|
<div class="ctrl-item">
|
|
<span>Tinggi Aquarium</span>
|
|
<input type="number" id="input-tinggi-aquarium" step="1">
|
|
<button class="btn-mulai" onclick="setTinggiAquarium()">Simpan</button>
|
|
</div>
|
|
|
|
<!-- MODE POMPA -->
|
|
<div class="ctrl-item">
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- BOX LAMPU -->
|
|
<div class="ctrl-box">
|
|
<div class="ctrl-box-title">Set Lampu</div>
|
|
|
|
<div class="ctrl-time">
|
|
<input type="text" id="jam-mulai">
|
|
<span style="font-weight:bold;">—</span>
|
|
<input type="text" id="jam-selesai">
|
|
</div>
|
|
|
|
<div style="display:flex; gap:10px; margin-top:10px; flex-wrap:wrap;">
|
|
<button class="btn-mulai" style="background:#e222;" onclick="setJadwalLampu()">Simpan Jadwal</button>
|
|
</div>
|
|
|
|
<!-- MODE LAMPU -->
|
|
<div class="ctrl-item" style="margin-top:12px;">
|
|
<span>Lampu</span>
|
|
<label class="switch">
|
|
<input type="checkbox" id="toggle-lampu" onchange="kontrolLampu(this.checked)">
|
|
<span class="slider"></span>
|
|
</label>
|
|
<span id="label-lampu" style="font-size:12px; color:#aaa;">OFF</span>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const NR = "<?= $noderedUrl ?>";
|
|
|
|
|
|
/* ===================== JADWAL LAMPU ===================== */
|
|
async function setJadwalLampu() {
|
|
|
|
const onTime = document.getElementById("jam-mulai").value.trim();
|
|
const offTime = document.getElementById("jam-selesai").value.trim();
|
|
|
|
if (!onTime || !offTime) {
|
|
showToast("Isi jam nyala dan jam mati terlebih dahulu", "error");
|
|
return;
|
|
}
|
|
|
|
const re = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
|
|
if (!re.test(onTime) || !re.test(offTime)) {
|
|
showToast("Format jam harus HH:MM", "error");
|
|
return;
|
|
}
|
|
|
|
// Menggunakan toggle yang ada di HTML
|
|
const isAuto = document.getElementById("toggle-lampu")?.checked ?? false;
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/api-schedule", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
on_time: onTime,
|
|
off_time: offTime,
|
|
enabled: isAuto
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
await response.text();
|
|
|
|
showToast("Jadwal lampu berhasil disimpan");
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast("Gagal menyimpan jadwal lampu", "error");
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* ===================== KONTROL LAMPU ===================== */
|
|
async function kontrolLampu(isOn) {
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/api-lampu", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
lampu: isOn ? "on" : "off"
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
await response.text();
|
|
|
|
document.getElementById("label-lampu").textContent = isOn ? "ON" : "OFF";
|
|
|
|
showToast("Lampu " + (isOn ? "ON" : "OFF"));
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast("Gagal mengontrol lampu", "error");
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* ===================== BATAS AMONIA ===================== */
|
|
async function setAmonia() {
|
|
|
|
const nilai = parseFloat(document.getElementById("input-amonia").value);
|
|
|
|
if (isNaN(nilai)) {
|
|
showToast("Masukkan nilai amonia", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/set-pmaks", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
maks: nilai
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
showToast("Batas amonia berhasil disimpan", "success");
|
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
showToast("Gagal menyimpan batas amonia", "error");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* ===================== BATAS KEKERUHAN ===================== */
|
|
async function setBatasKekeruhan() {
|
|
|
|
const nilai = parseFloat(document.getElementById("input-kekeruhan").value);
|
|
|
|
if (isNaN(nilai)) {
|
|
showToast("Masukkan nilai kekeruhan", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/set-batas_kekeruhan", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
max_kekeruhan: nilai
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
await response.text();
|
|
|
|
showToast("Batas kekeruhan berhasil disimpan");
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast("Gagal menyimpan batas kekeruhan", "error");
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* ===================== BATAS TINGGI AIR ===================== */
|
|
async function setBatasTinggiAir() {
|
|
|
|
const nilai = parseFloat(document.getElementById("input-tinggi-air").value);
|
|
|
|
if (isNaN(nilai)) {
|
|
showToast("Masukkan tinggi air", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/set-batas_distance", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
min_jarak: nilai
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
await response.text();
|
|
|
|
showToast("Batas tinggi air berhasil disimpan");
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast("Gagal menyimpan batas tinggi air", "error");
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* ===================== TINGGI AQUARIUM ===================== */
|
|
async function setTinggiAquarium() {
|
|
|
|
const nilai = parseFloat(document.getElementById("input-tinggi-aquarium").value);
|
|
|
|
if (isNaN(nilai)) {
|
|
showToast("Masukkan tinggi aquarium", "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
|
|
const response = await fetch(NR + "/set-tinggi_aquarium", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
tinggi_aquarium: nilai
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status);
|
|
}
|
|
|
|
await response.text();
|
|
|
|
showToast("Tinggi aquarium berhasil disimpan");
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast("Gagal menyimpan tinggi aquarium", "error");
|
|
}
|
|
|
|
}
|
|
|
|
/* ── Load status terkini dari Node-RED /get-status ────── */
|
|
async function loadStatus() {
|
|
try {
|
|
const res = await fetch(NR + '/get-status', { cache: 'no-store' });
|
|
if (!res.ok) return;
|
|
const d = await res.json();
|
|
if (!d) return;
|
|
|
|
// Pompa
|
|
if (d.relay !== undefined) {
|
|
const on = d.relay == 1;
|
|
|
|
const statusPompa = document.getElementById('status-pompa');
|
|
if (statusPompa) {
|
|
statusPompa.textContent = on ? 'ON' : 'OFF';
|
|
statusPompa.style.color = on ? '#22c55e' : '#ef4444';
|
|
}
|
|
}
|
|
|
|
if (d.pompa_mode !== undefined) {
|
|
const isAuto = d.pompa_mode === 'AUTO';
|
|
|
|
const togglePompa = document.getElementById('toggle-pompa-auto');
|
|
if (togglePompa) togglePompa.checked = isAuto;
|
|
|
|
const labelPompa = document.getElementById('label-pompa-mode');
|
|
if (labelPompa) labelPompa.textContent = isAuto ? 'Auto' : 'Manual';
|
|
}
|
|
if (d.batas_amonia !== undefined) {
|
|
document.getElementById('input-amonia').value = d.batas_amonia;
|
|
}
|
|
|
|
if (d.batas_turbidity !== undefined) {
|
|
document.getElementById('input-kekeruhan').value = d.batas_turbidity;
|
|
}
|
|
|
|
if (d.batas_distance !== undefined) {
|
|
document.getElementById('input-tinggi-air').value = d.batas_distance;
|
|
}
|
|
|
|
if (d.tinggi_aquarium !== undefined) {
|
|
document.getElementById('input-tinggi-aquarium').value = d.tinggi_aquarium;
|
|
}
|
|
|
|
// Lampu
|
|
if (d.lampu !== undefined) {
|
|
const on = d.lampu == 1;
|
|
|
|
const labelLampu = document.getElementById('label-lampu');
|
|
if (labelLampu) {
|
|
labelLampu.textContent = on ? 'ON' : 'OFF';
|
|
}
|
|
|
|
const toggleLampu = document.getElementById('toggle-lampu');
|
|
if (toggleLampu) {
|
|
toggleLampu.checked = on;
|
|
}
|
|
}
|
|
|
|
const jamMulai = document.getElementById("jam-mulai");
|
|
|
|
if (jamMulai && !jamMulai.value && d.lampu_on_time) {
|
|
jamMulai.value = d.lampu_on_time.substring(0,5);
|
|
}
|
|
|
|
const jamSelesai = document.getElementById("jam-selesai");
|
|
|
|
if (jamSelesai && !jamSelesai.value && d.lampu_off_time) {
|
|
jamSelesai.value = d.lampu_off_time.substring(0,5);
|
|
}
|
|
|
|
} catch(e) {
|
|
console.warn('[kontrol] Gagal load status:', e.message);
|
|
}
|
|
}
|
|
|
|
/* ── Toast ────────────────────────────────────────────── */
|
|
function showToast(message, type = "success") {
|
|
|
|
let toast = document.getElementById("nr-toast");
|
|
|
|
if (!toast) {
|
|
toast = document.createElement("div");
|
|
toast.id = "nr-toast";
|
|
|
|
toast.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
min-width: 320px;
|
|
padding: 15px 20px;
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
z-index: 99999;
|
|
opacity: 0;
|
|
transform: translateX(100%);
|
|
transition: all .35s ease;
|
|
box-shadow: 0 8px 20px rgba(0,0,0,.25);
|
|
`;
|
|
|
|
document.body.appendChild(toast);
|
|
}
|
|
|
|
if (type === "success") {
|
|
toast.style.background = "#22c55e";
|
|
toast.innerHTML = "✅ " + message;
|
|
} else {
|
|
toast.style.background = "#ef4444";
|
|
toast.innerHTML = "❌ " + message;
|
|
}
|
|
|
|
toast.style.opacity = "1";
|
|
toast.style.transform = "translateX(0)";
|
|
|
|
clearTimeout(toast.timer);
|
|
|
|
toast.timer = setTimeout(() => {
|
|
toast.style.opacity = "0";
|
|
toast.style.transform = "translateX(100%)";
|
|
}, 3000);
|
|
}
|
|
/* ── Dropdown ─────────────────────────────────────────── */
|
|
const notifBtn = document.getElementById('notifBtn');
|
|
const notifDropdown = document.getElementById('notifDropdown');
|
|
const userBtn = document.getElementById('userBtn');
|
|
const userDropdown = document.getElementById('userDropdown');
|
|
notifBtn.onclick = e => { e.stopPropagation(); notifDropdown.classList.toggle('show'); userDropdown.classList.remove('show'); };
|
|
userBtn.onclick = e => { e.stopPropagation(); userDropdown.classList.toggle('show'); notifDropdown.classList.remove('show'); };
|
|
document.onclick = () => { notifDropdown.classList.remove('show'); userDropdown.classList.remove('show'); };
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadStatus();
|
|
setInterval(loadStatus, 10000); // refresh status tiap 10 detik
|
|
});
|
|
</script>
|
|
|
|
<script>
|
|
/**
|
|
* NODERED_URL = URL server Node-RED (misal http://192.168.1.x:1880)
|
|
* node-red.js akan panggil NODERED_URL + "/get-sensor"
|
|
*
|
|
* FIREBASE_DB_URL dipakai sebagai fallback jika NODERED_URL tidak diset.
|
|
*
|
|
* Ganti IP sesuai server Node-RED kamu.
|
|
*/
|
|
|
|
window.NODERED_URL = "https://node-red.aquamonn.online";
|
|
window.FIREBASE_DB_URL = "<?= rtrim(FIREBASE_DB_URL, '/') ?>";
|
|
|
|
// Jika NODERED_URL tidak dikonfigurasi di PHP, kosongkan agar
|
|
// node-red.js fallback ke Firebase langsung.
|
|
if (!window.NODERED_URL) {
|
|
// Firebase path sesuai flow:
|
|
// https://aquascape-67707-default-rtdb.firebaseio.com/sensor.json
|
|
window.FIREBASE_DB_URL = window.FIREBASE_DB_URL || "https://aquascape-67707-default-rtdb.firebaseio.com";
|
|
}
|
|
|
|
window.onSensorUpdate = function(ok) {
|
|
const dot = document.getElementById("conn-status");
|
|
const label = document.getElementById("conn-label");
|
|
const ts = document.getElementById("last-update");
|
|
if (!dot) return;
|
|
dot.className = ok ? "online" : "offline";
|
|
label.textContent = ok ? "Live" : "Offline";
|
|
if (ok) ts.textContent = " · " + new Date().toLocaleTimeString("id-ID");
|
|
};
|
|
</script>
|
|
|
|
<script src="assets/js/node-red.js"></script>
|
|
<?php require_once 'partials/footer.php'; ?>
|