Bab 43 — Aplikasi Data Cepat dan Dasbor: Dari Skrip ke Layar Orang Lain

Seri I5 — Aplikasi Pertama: Web, Peta, dan Dasbor

CatatanDi bab ini, Anda akan:
  • Membuat dasbor dengan Streamlit dan mengujinya tanpa peramban (AppTest)
  • Membuat demo fungsi dengan Gradio dan Dash untuk dasbor kustom, lalu mengujinya
  • Menampilkan peta interaktif (folium dan pydeck) di dasbor
  • Mengenal HTMX untuk web ringan dan FastAPI untuk API data
  • Memahami cara menyajikan aplikasi ke pengguna, lalu menutup Seri I5

Semua skrip bab ini ada di folder kode/bab43/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip).

Bab 42 menutup dengan dasbor statis: bagus, tetapi beku. Kali ini kita membuat dasbor yang hidup: pengguna memilih sektor, angkanya berubah, peta bergeser. Untungnya Python punya pustaka yang membuat ini mungkin tanpa harus belajar JavaScript. Bayangkan sebuah toko: dulu Anda menempel daftar harga di dinding (dasbor statis); sekarang Anda punya kasir yang menjawab pertanyaan pembeli satu per satu.

Memilih alat untuk aplikasi data.

Kita memakai data unit usaha tiruan yang sama dengan bab sebelumnya (15 unit, lima sektor, semua angka fiktif). Semua pustaka di bawah sudah terpasang di komputer penulis dan diuji dengan menjalankannya; versi pustaka terus berubah, jadi bila perilakunya berbeda di komputer Anda, periksa dokumentasi resmi terbaru.

Satu keterbatasan yang jujur: bab ini tidak bisa memperlihatkan tampilan di peramban. Semua yang dijalankan di sini diuji lewat kode (tanpa membuka peramban), sehingga yang terbukti adalah logika dan isinya, bukan penampilannya.

Streamlit: dasbor dari satu skrip

Streamlit mengubah skrip Python biasa menjadi halaman web. Idenya sederhana: skrip dibaca dari atas ke bawah, dan setiap kali pengguna mengubah sesuatu (memilih, menggeser), seluruh skrip dijalankan ulang. Tidak ada HTML, tidak ada JavaScript.

app_streamlit.py
# Bab 43 - Dasbor Streamlit per sektor (modul bantu; dijalankan dengan: streamlit run app_streamlit.py)
import pandas as pd
import streamlit as st

from data_unit import baca_unit
from peta import buat_peta

SEKTOR = ["semua", "hutan", "kebun", "sawah", "kolam", "kandang"]


@st.cache_data                      # hasil disimpan; tidak membaca CSV ulang setiap interaksi
def muat_unit():
    return pd.DataFrame(baca_unit())


st.set_page_config(page_title="Dasbor unit usaha", layout="wide")
st.title("Dasbor unit usaha (data fiktif)")

sektor = st.sidebar.selectbox("Sektor", SEKTOR)
unit = muat_unit()
tampil = unit if sektor == "semua" else unit[unit["sektor"] == sektor]

kiri, tengah, kanan = st.columns(3)
kiri.metric("Jumlah unit", len(tampil))
tengah.metric("Rata-rata nilai", f"{tampil['nilai'].mean():.1f}")
kanan.metric("Nilai tertinggi", f"{tampil['nilai'].max():.1f}")

if sektor == "semua":
    st.info("Nilai tiap sektor berbeda satuan (cm atau kg): pilih satu sektor untuk membandingkan.")
else:
    st.bar_chart(tampil.set_index("unit")["nilai"])

st.dataframe(tampil[["id", "sektor", "unit", "jenis", "nilai", "satuan"]], hide_index=True)
st.map(tampil, latitude="lat", longitude="lon")

with st.expander("Peta interaktif (folium)"):
    st.iframe(buat_peta(tampil.to_dict("records")).get_root().render(), height=420)

Bacalah seperti resep:

  • st.sidebar.selectbox(...) membuat kotak pilihan di bilah samping dan mengembalikan pilihan sekarang.
  • st.columns(3) dan .metric(...) membuat tiga kartu angka.
  • st.bar_chart, st.dataframe, dan st.map membuat grafik, tabel, dan peta titik. st.map diberi nama kolom lintang dan bujur (lat dan lon).
  • @st.cache_data menyimpan hasil muat_unit() supaya CSV tidak dibaca ulang setiap interaksi.
  • Bagian terakhir menanam peta folium dalam sebuah lipatan (dibahas di bagian peta).

Aplikasi ini dijalankan dengan streamlit run app_streamlit.py, yang membuka alamat di komputer Anda. Perintah itu tidak dijalankan saat buku dibuat, sebab ia menyalakan server yang menunggu peramban. Sebagai gantinya, Streamlit menyediakan AppTest (dokumentasi resmi: pengujian tanpa peramban): ia menjalankan skrip di memori dan memberi akses ke elemennya.

01_streamlit_apptest.py
# Bab 43 - Streamlit: menjalankan dasbor TANPA peramban memakai AppTest
import logging

from streamlit.testing.v1 import AppTest

# "bare mode": AppTest tidak punya peramban, jadi Streamlit mengeluh pelan-pelan; diamkan peringatannya
logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)

at = AppTest.from_file("app_streamlit.py", default_timeout=30).run()

print("ada galat      :", bool(at.exception))
print("judul          :", at.title[0].value)
print("pilihan sektor :", at.selectbox[0].options)
print("sektor terpilih:", at.selectbox[0].value)
for m in at.metric:
    print(f"metrik         : {m.label} = {m.value}")
print("baris tabel    :", len(at.dataframe[0].value))
print("kotak info     :", at.info[0].value[:40] + "...")
ada galat      : False
judul          : Dasbor unit usaha (data fiktif)
pilihan sektor : ['semua', 'hutan', 'kebun', 'sawah', 'kolam', 'kandang']
sektor terpilih: semua
metrik         : Jumlah unit = 15
metrik         : Rata-rata nilai = 375.0
metrik         : Nilai tertinggi = 1250.0
baris tabel    : 15
kotak info     : Nilai tiap sektor berbeda satuan (cm ata...

Pada awal, sektor terpilih adalah semua, dan angka-angkanya benar: 15 unit, rata-rata nilai 375,0 (angka ini mencampur cm dan kg, sehingga aplikasi menampilkan kotak info yang memperingatkannya), dan nilai tertinggi 1250,0 (petak sawah 1). Kini kita meniru pengguna:

02_streamlit_interaksi.py
# Bab 43 - Streamlit: meniru pengguna memilih sektor, lalu memeriksa hasilnya
import logging

from streamlit.testing.v1 import AppTest

logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)

at = AppTest.from_file("app_streamlit.py", default_timeout=30).run()

for sektor in ["hutan", "kolam", "kandang"]:
    at.selectbox[0].select(sektor).run()           # seperti pengguna mengklik pilihan di bilah samping
    nilai = {m.label: m.value for m in at.metric}
    grafik = len(at.get("vega_lite_chart"))        # st.bar_chart = elemen grafik Vega-Lite
    peta = len(at.get("deck_gl_json_chart"))       # st.map = elemen peta deck.gl
    print(f"{sektor:8} unit={nilai['Jumlah unit']} rata-rata={nilai['Rata-rata nilai']} "
          f"tertinggi={nilai['Nilai tertinggi']} grafik={grafik} peta={peta} baris={len(at.dataframe[0].value)}")

# Memilih sesuatu yang tidak ada dalam daftar gagal ketika aplikasi dijalankan ulang
try:
    at.selectbox[0].select("empang").run()
except ValueError as e:
    print("pilihan tidak sah ditolak:", type(e).__name__)
hutan    unit=3 rata-rata=35.2 tertinggi=45.2 grafik=1 peta=1 baris=3
kolam    unit=3 rata-rata=369.3 tertinggi=432.4 grafik=1 peta=1 baris=3
kandang  unit=3 rata-rata=383.8 tertinggi=412.5 grafik=1 peta=1 baris=3
pilihan tidak sah ditolak: ValueError

at.selectbox[0].select("kolam").run() adalah “pengguna memilih kolam, skrip berjalan ulang”. Hasilnya cocok dengan hitungan tangan: kolam punya 3 unit, rata-rata (432,4 + 380,0 + 295,5) / 3 = 369,3, tertinggi 432,4. Grafik batang dan peta muncul sebagai satu elemen masing-masing. Memilih sesuatu yang tidak ada di daftar (empang) dijawab ValueError saat aplikasi dijalankan ulang.

CatatanINGAT

AppTest membuktikan bahwa logika aplikasi benar dan tidak ada pengecualian. Ia tidak membuktikan bahwa grafik tampak cantik. Grafik dan peta hanya terlihat sebagai elemen bertipe tertentu (vega_lite_chart, deck_gl_json_chart); bentuk di layar tetap harus dilihat manusia.

Gradio: demo fungsi dan model

Gradio punya tujuan sedikit berbeda dari Streamlit: membungkus sebuah fungsi dengan kotak masukan dan keluaran otomatis. Sangat disukai untuk memamerkan model: pengguna mengetik sesuatu, model menjawab. Di sini “model” kita sengaja sederhana: rumus taksiran bobot ikan dari panjangnya, berupa contoh fiktif, bukan hasil penelitian.

app_gradio.py
# Bab 43 - Demo Gradio: cari unit dan "model" taksir bobot ikan (modul bantu)
import gradio as gr

from data_unit import baca_unit

SEKTOR = ["semua", "hutan", "kebun", "sawah", "kolam", "kandang"]


def cari_unit(sektor, nilai_minimal):
    """Mengembalikan tabel unit dan kalimat ringkasan."""
    terpilih = [
        [u["id"], u["sektor"], u["unit"], u["nilai"], u["satuan"]]
        for u in baca_unit()
        if sektor in ("semua", u["sektor"]) and u["nilai"] >= nilai_minimal
    ]
    return terpilih, f"{len(terpilih)} unit cocok"


def taksir_bobot(panjang_cm):
    """Pengganti 'model': bobot = 0.01 x panjang^3 (rumus contoh FIKTIF, bukan hasil penelitian)."""
    return round(0.01 * panjang_cm ** 3, 1)


antarmuka_cari = gr.Interface(
    fn=cari_unit,
    inputs=[gr.Dropdown(SEKTOR, value="semua", label="Sektor"),
            gr.Slider(0, 1000, value=0, step=10, label="Nilai minimal")],
    outputs=[gr.Dataframe(headers=["id", "sektor", "unit", "nilai", "satuan"], label="Unit"),
             gr.Textbox(label="Ringkasan")],
    api_name="cari",
    flagging_mode="never",
)
antarmuka_taksir = gr.Interface(
    fn=taksir_bobot,
    inputs=gr.Number(value=25, label="Panjang ikan (cm)"),
    outputs=gr.Number(label="Taksiran bobot (g)"),
    api_name="taksir",
    flagging_mode="never",
)
demo = gr.TabbedInterface([antarmuka_cari, antarmuka_taksir], ["Cari unit", "Taksir bobot"],
                          title="Demo unit usaha (data fiktif)")

if __name__ == "__main__":
    demo.launch()          # alamat lokal saja; share=True TIDAK dipakai

Dua fungsi, dua gr.Interface, digabung dalam tab. api_name memberi nama pada tiap titik akses supaya bisa dipanggil dari program lain. Ada dua cara menguji:

03_gradio_fungsi.py
# Bab 43 - Gradio: fungsinya diuji langsung, lalu aplikasinya dinyalakan di komputer sendiri
import contextlib
import io
import os

os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"      # jangan kirim statistik pemakaian
os.environ["HF_HUB_OFFLINE"] = "1"                    # jangan menghubungi huggingface.co

from gradio_client import Client

from app_gradio import cari_unit, demo, taksir_bobot

# 1. Fungsi biasa: tidak perlu server untuk mengujinya
tabel, ringkasan = cari_unit("kolam", 300)
print("langsung    :", ringkasan, "->", [baris[2] for baris in tabel])
print("langsung    :", taksir_bobot(25), "g untuk ikan 25 cm")

# 2. Nyalakan server lokal, lalu panggil lewat klien resmi (seperti pengguna lewat jaringan)
demo.launch(server_name="127.0.0.1", prevent_thread_lock=True, share=False, quiet=True)
try:
    klien = Client(demo.local_url, verbose=False)
    data, teks = klien.predict("kandang", 0, api_name="/cari")
    print("klien cari  :", teks, "->", [b[2] for b in data["data"]])
    print("klien taksir:", klien.predict(30, api_name="/taksir"), "g untuk ikan 30 cm")
finally:
    with contextlib.redirect_stdout(io.StringIO()):   # menyembunyikan pesan nomor port
        demo.close()
print("server ditutup")
langsung    : 2 unit cocok -> ['Kolam A', 'Kolam B']
langsung    : 156.2 g untuk ikan 25 cm
klien cari  : 3 unit cocok -> ['Kandang 1', 'Kandang 2', 'Kandang 3']
klien taksir: 270.0 g untuk ikan 30 cm
server ditutup
  1. Panggil fungsinya langsung: 2 unit kolam bernilai ≥ 300 (Kolam A dan B), dan 0,01 × 25³ = 156,25, yang dibulatkan Python menjadi 156,2 g.
  2. Nyalakan server lokal lalu panggil lewat gradio_client, klien resmi: seperti pengguna jarak jauh. 3 unit kandang cocok, dan ikan 30 cm: 0,01 × 30³ = 270,0 g.

Tiga catatan keamanan dan privasi:

  • Kita tidak memakai share=True. Menurut dokumentasi Gradio, opsi itu membuat tautan publik sementara (berlaku sekitar satu pekan) yang meneruskan lalu lintas ke komputer Anda; siapa pun yang punya tautan bisa memakai aplikasi, sementara komputer Anda yang bekerja. Jangan lakukan itu pada data yang bukan untuk umum.
  • Saat menguji, kami memantau sambungan jaringan dan menemukan bahwa Gradio menghubungi huggingface.co bila tidak dicegah. Kami tidak menyelidiki tujuannya. Dengan HF_HUB_OFFLINE=1 (dan GRADIO_ANALYTICS_ENABLED=False untuk statistik pemakaian), tidak ada lagi sambungan ke luar komputer yang terdeteksi. Bila Anda memakai Gradio untuk data sensitif, periksa pengaturan ini.
  • demo.launch() tanpa argumen hanya melayani komputer Anda sendiri.

Dash: dasbor yang sepenuhnya Anda atur

Dash (dari Plotly) lebih “dari bawah”: Anda menyusun tata letak (kerangka halaman dari komponen) dan callback (fungsi yang dipanggil bila sebuah komponen berubah). Lebih banyak kode daripada Streamlit, tetapi kendalinya lebih halus.

app_dash.py
# Bab 43 - Dasbor Dash (modul bantu; dijalankan dengan: python app_dash.py)
import pandas as pd
import plotly.express as px
from dash import Dash, Input, Output, dcc, html

from data_unit import baca_unit

SEKTOR = ["hutan", "kebun", "sawah", "kolam", "kandang"]
unit = pd.DataFrame(baca_unit())

app = Dash(__name__)
app.layout = html.Div([
    html.H1("Dasbor Dash (data fiktif)"),
    dcc.Dropdown(id="sektor", options=SEKTOR, value="kolam", clearable=False),
    html.Div(id="ringkasan"),
    dcc.Graph(id="grafik"),
])


@app.callback(Output("grafik", "figure"), Output("ringkasan", "children"), Input("sektor", "value"))
def perbarui(sektor):
    bagian = unit[unit["sektor"] == sektor]
    gambar = px.bar(bagian, x="unit", y="nilai", title=f"Sektor {sektor}")
    return gambar, f"{len(bagian)} unit, rata-rata {bagian['nilai'].mean():.1f} {bagian['satuan'].iloc[0]}"


if __name__ == "__main__":
    app.run(debug=False)       # http://127.0.0.1:8050

Dekorator @app.callback berkata: “bila nilai sektor berubah, panggil fungsi ini dan taruh hasilnya di grafik.figure dan ringkasan.children.” Dash berdiri di atas Flask, sehingga kita bisa memakai klien uji Flask tanpa peramban:

04_dash_tata_letak.py
# Bab 43 - Dash: menguji dasbor lewat klien uji Flask, tanpa peramban
from app_dash import app, perbarui

klien = app.server.test_client()          # Dash berdiri di atas Flask

halaman = klien.get("/")
print("halaman utama:", halaman.status_code, "| memuat penyaji Dash:", b"_dash-renderer" in halaman.data)

tata_letak = klien.get("/_dash-layout").get_json()
anak = tata_letak["props"]["children"]
print("tata letak   :", [(a["type"], a["props"].get("id")) for a in anak])

# Meniru permintaan yang dikirim peramban saat pengguna mengganti sektor
isi = {
    "output": "..grafik.figure...ringkasan.children..",
    "outputs": [{"id": "grafik", "property": "figure"}, {"id": "ringkasan", "property": "children"}],
    "inputs": [{"id": "sektor", "property": "value", "value": "sawah"}],
    "changedPropIds": ["sektor.value"],
}
jawab = klien.post("/_dash-update-component", json=isi)
print("callback     :", jawab.status_code, jawab.get_json()["response"]["ringkasan"]["children"])

# Fungsinya juga bisa dipanggil langsung seperti fungsi biasa
print("langsung     :", perbarui("kandang")[1])
halaman utama: 200 | memuat penyaji Dash: True
tata letak   : [('H1', None), ('Dropdown', 'sektor'), ('Div', 'ringkasan'), ('Graph', 'grafik')]
callback     : 200 2 unit, rata-rata 1115.2 kg
langsung     : 3 unit, rata-rata 383.8 kg

Halaman utama menjawab 200 dan memuat penyaji Dash. Tata letak berisi judul H1, daftar pilihan sektor, ringkasan, dan grafik. Kita lalu mengirim permintaan yang sama seperti yang dikirim peramban saat pengguna memilih sawah: jawabannya “2 unit, rata-rata 1115.2 kg”, cocok dengan (1250,0 + 980,5) / 2 = 1115,25 yang dibulatkan satu desimal. Fungsi callback juga bisa dipanggil langsung.

TipTIPS

Pegangan memilih: Streamlit untuk dasbor cepat dan prototipe; Gradio untuk memamerkan satu fungsi atau model; Dash bila tata letak dan interaksi perlu diatur rinci. Untuk dasbor internal sederhana, mulailah dari Streamlit.

Peta interaktif di dasbor

Di Bab 38 sampai 40 Anda membuat peta. Untuk peta di dasbor, folium membungkus pustaka peta JavaScript Leaflet; pydeck membungkus deck.gl untuk lapisan titik atau data besar. Keduanya menghasilkan HTML.

peta.py
# Bab 43 - Modul bantu: peta interaktif lokasi unit usaha dengan folium (koordinat FIKTIF)
import folium

WARNA = {"hutan": "darkgreen", "kebun": "orange", "sawah": "green", "kolam": "blue", "kandang": "red"}


def buat_peta(unit):
    """Satu penanda per unit, diberi warna menurut sektor dan keterangan saat diklik."""
    tengah = [sum(u["lat"] for u in unit) / len(unit), sum(u["lon"] for u in unit) / len(unit)]
    peta = folium.Map(location=tengah, zoom_start=13, tiles="OpenStreetMap")
    for u in unit:
        folium.Marker(
            [u["lat"], u["lon"]],
            tooltip=f'{u["unit"]} ({u["sektor"]})',
            popup=f'{u["unit"]}: {u["nilai"]} {u["satuan"]}',
            icon=folium.Icon(color=WARNA[u["sektor"]]),
        ).add_to(peta)
    return peta
05_peta_interaktif.py
# Bab 43 - Peta interaktif: folium (Leaflet) dan pydeck (deck.gl), disimpan sebagai berkas HTML
from pathlib import Path

import pydeck as pdk

from data_unit import baca_unit
from peta import buat_peta

unit = baca_unit()
keluar = Path(__file__).parent / "_tmp"
keluar.mkdir(exist_ok=True)

# 1. folium: satu penanda per unit
peta = buat_peta(unit)
berkas = keluar / "peta_folium.html"
peta.save(berkas)
html = berkas.read_text(encoding="utf-8")
print("folium : penanda =", html.count("L.marker("), "| rujukan Leaflet:", "leaflet" in html.lower())
print("         berkas HTML dibuat:", berkas.stat().st_size > 5000)

# 2. pydeck: lapisan titik dengan ukuran sebanding nilai (untuk perbandingan sektor yang sama satuannya)
kolam = [u for u in unit if u["sektor"] == "kolam"]
lapisan = pdk.Layer("ScatterplotLayer", data=kolam, get_position=["lon", "lat"],
                    get_radius="nilai", radius_scale=0.5, get_fill_color=[30, 100, 200, 160])
tampilan = pdk.ViewState(latitude=kolam[0]["lat"], longitude=kolam[0]["lon"], zoom=13)
deck = pdk.Deck(layers=[lapisan], initial_view_state=tampilan)
berkas2 = keluar / "peta_pydeck.html"
deck.to_html(str(berkas2), open_browser=False)
print("pydeck : titik kolam =", len(kolam), "| berkas HTML dibuat:", berkas2.stat().st_size > 1000)

# Isi kedua berkas memuat pustaka peta dari internet saat DIBUKA di peramban (bukan saat dibuat di sini)
print("pustaka dimuat dari CDN:", "https://" in html and "https://" in berkas2.read_text(encoding="utf-8"))
folium : penanda = 15 | rujukan Leaflet: True
         berkas HTML dibuat: True
pydeck : titik kolam = 3 | berkas HTML dibuat: True
pustaka dimuat dari CDN: True

Ada 15 penanda (satu per unit) pada peta folium, dan 3 titik kolam pada peta pydeck. Perhatikan baris terakhir: kedua berkas memuat pustaka petanya dari internet saat dibuka di peramban, bukan saat dibuat. Jadi peta ini butuh sambungan internet untuk dilihat; kami tidak membukanya di sini. Streamlit menanam peta folium memakai st.iframe(...). (Fungsi lama st.components.v1.html ditandai usang oleh Streamlit 1.65 dengan st.iframe sebagai penggantinya.) Dokumentasi Streamlit juga memperingatkan: jangan menanam HTML dari sumber tak tepercaya lewat cara ini; peta kita dibuat sendiri dari data kita.

PeringatanAWAS

Ukuran titik pada peta pydeck memakai kolom nilai. Itu adil hanya untuk sektor dengan satuan sama. Kita memilih sektor kolam (semua dalam kg). Menggambar hutan (cm) berdampingan dengan kolam (kg) pada satu skala akan menyesatkan.

HTMX: web ringan tanpa kerangka besar

Kadang yang Anda butuhkan hanya halaman yang sebagian isinya berganti tanpa memuat ulang seluruh halaman. HTMX adalah pustaka JavaScript kecil yang memberi HTML kemampuan itu lewat atribut: hx-get (minta ke alamat ini), hx-trigger (kapan), hx-target (isi bagian mana), hx-swap (cara mengganti). Servernya, di sini Flask, cukup mengirim potongan HTML, bukan JSON.

app_htmx.py
# Bab 43 - Web ringan dengan HTMX + Flask (modul bantu; dijalankan dengan: flask --app app_htmx run)
from flask import Flask, abort, render_template_string, request

from data_unit import baca_unit

app = Flask(__name__)
SEKTOR = ["hutan", "kebun", "sawah", "kolam", "kandang"]

HALAMAN = """<!doctype html>
<html lang="id">
<head>
  <meta charset="utf-8"><title>Unit usaha (HTMX)</title>
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"></script>
</head>
<body>
  <h1>Unit usaha (data fiktif)</h1>
  <select name="s" hx-get="/sektor" hx-trigger="change" hx-target="#hasil" hx-swap="innerHTML">
    <option value="">- pilih sektor -</option>
    {% for s in sektor %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
  </select>
  <div id="hasil">Pilih sektor di atas.</div>
</body>
</html>"""

TABEL = """<table>
  {% for u in unit %}<tr><td>{{ u.unit }}</td><td>{{ u.jenis }}</td><td>{{ u.nilai }} {{ u.satuan }}</td></tr>
  {% endfor %}
</table>"""


@app.get("/")
def indeks():
    return render_template_string(HALAMAN, sektor=SEKTOR)


@app.get("/sektor")
def per_sektor():
    s = request.args.get("s", "")
    if s not in SEKTOR:
        abort(400, "sektor tidak dikenal")
    unit = [u for u in baca_unit() if u["sektor"] == s]
    return render_template_string(TABEL, unit=unit)      # hanya potongan HTML, bukan satu halaman penuh

Pemilihan sektor mengirim GET /sektor?s=kolam, dan server menjawab tabel kecil yang ditaruh di #hasil. Skrip HTMX dimuat dari CDN jsDelivr; versi 2.0.11 adalah versi terbaru di registri npm saat ini ditulis (dibaca 2026-10-10). Pada penerapan sungguhan, Anda bisa menambahkan atribut integrity (Subresource Integrity) seperti pada contoh dokumentasi HTMX; kami tidak menyalin hash itu dari ingatan.

06_htmx_flask.py
# Bab 43 - HTMX + Flask: sisi server diuji dengan klien uji (JavaScript htmx TIDAK dijalankan di sini)
from app_htmx import app

klien = app.test_client()

halaman = klien.get("/").get_data(as_text=True)
print("halaman utama:", len(halaman.splitlines()), "baris")
for atribut in ("hx-get=", "hx-trigger=", "hx-target=", "htmx.org@2.0.11"):
    print(f"  memuat {atribut:18}", atribut in halaman)

# Yang dikirim htmx saat pengguna memilih: GET /sektor?s=kolam dengan header HX-Request
jawab = klien.get("/sektor?s=kolam", headers={"HX-Request": "true"})
potongan = jawab.get_data(as_text=True)
print("\nGET /sektor?s=kolam ->", jawab.status_code, "| potongan HTML, bukan halaman penuh:", "<html" not in potongan)
print(potongan)

print("sektor aneh ->", klien.get("/sektor?s=empang").status_code)
print("tanpa sektor ->", klien.get("/sektor").status_code)
halaman utama: 15 baris
  memuat hx-get=            True
  memuat hx-trigger=        True
  memuat hx-target=         True
  memuat htmx.org@2.0.11    True

GET /sektor?s=kolam -> 200 | potongan HTML, bukan halaman penuh: True
<table>
  <tr><td>Kolam A</td><td>nila</td><td>432.4 kg</td></tr>
  <tr><td>Kolam B</td><td>lele</td><td>380.0 kg</td></tr>
  <tr><td>Kolam C</td><td>nila</td><td>295.5 kg</td></tr>
  
</table>
sektor aneh -> 400
tanpa sektor -> 400

Yang terbukti: halaman memuat atribut HTMX dan skrip versi 2.0.11; endpoint /sektor menjawab potongan HTML (bukan halaman penuh) berisi tiga kolam; sektor aneh atau kosong ditolak dengan kode 400. Yang tidak teruji: JavaScript HTMX di peramban, yaitu bahwa memilih sektor benar-benar mengganti isi #hasil. Itu butuh peramban dan kami tidak membukanya.

FastAPI: pintu untuk program lain

Dasbor adalah pintu untuk manusia. API adalah pintu untuk program lain: aplikasi ponsel, skrip lain, atau dasbor di tempat lain yang meminta data dalam bentuk JSON. Di Bab 36 Anda mengenal FastAPI; kini kita memakainya untuk data semua unit usaha, dengan model Pydantic dari Bab 34.

api_unit.py
# Bab 43 - API FastAPI untuk data semua unit usaha (modul bantu)
from typing import Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from data_unit import baca_unit, ringkas_per_sektor

Sektor = Literal["hutan", "kebun", "sawah", "kolam", "kandang"]


class Unit(BaseModel):
    id: str
    sektor: Sektor
    unit: str
    jenis: str
    lat: float
    lon: float
    nilai: float
    satuan: str


class Ringkasan(BaseModel):
    sektor: Sektor
    jumlah: int
    total: float
    rata: float
    satuan: str


app = FastAPI(title="API unit usaha (data fiktif)")


@app.get("/unit", response_model=list[Unit])
def daftar_unit(sektor: Sektor | None = None):
    return [u for u in baca_unit() if sektor is None or u["sektor"] == sektor]


@app.get("/unit/{id_unit}", response_model=Unit)
def satu_unit(id_unit: str):
    for u in baca_unit():
        if u["id"] == id_unit:
            return u
    raise HTTPException(status_code=404, detail=f"unit {id_unit} tidak ada")


@app.get("/ringkasan", response_model=list[Ringkasan])
def ringkasan():
    return [{"sektor": s, **r} for s, r in ringkas_per_sektor(baca_unit()).items()]
07_fastapi_api.py
# Bab 43 - FastAPI: API data semua unit usaha, diuji dengan TestClient
from fastapi.testclient import TestClient

from api_unit import app

klien = TestClient(app)

semua = klien.get("/unit").json()
print("GET /unit              :", len(semua), "unit; pertama =", semua[0]["id"], semua[0]["unit"])

kolam = klien.get("/unit", params={"sektor": "kolam"}).json()
print("GET /unit?sektor=kolam :", [u["unit"] for u in kolam])

satu = klien.get("/unit/D2")
print("GET /unit/D2           :", satu.status_code, satu.json()["nilai"], satu.json()["satuan"])

hilang = klien.get("/unit/X9")
print("GET /unit/X9           :", hilang.status_code, hilang.json())

salah = klien.get("/unit", params={"sektor": "empang"})
print("GET /unit?sektor=empang:", salah.status_code, salah.json()["detail"][0]["type"])

print("GET /ringkasan         :", [(r["sektor"], r["jumlah"], round(r["rata"], 1)) for r in klien.get("/ringkasan").json()])
print("dokumentasi otomatis   :", klien.get("/docs").status_code, "| skema:", klien.get("/openapi.json").status_code)
GET /unit              : 15 unit; pertama = H1 Plot A
GET /unit?sektor=kolam : ['Kolam A', 'Kolam B', 'Kolam C']
GET /unit/D2           : 200 412.5 kg
GET /unit/X9           : 404 {'detail': 'unit X9 tidak ada'}
GET /unit?sektor=empang: 422 literal_error
GET /ringkasan         : [('hutan', 3, 35.2), ('kebun', 4, 257.5), ('sawah', 2, 1115.2), ('kolam', 3, 369.3), ('kandang', 3, 383.8)]
dokumentasi otomatis   : 200 | skema: 200

TestClient memanggil API tanpa menyalakan server. Hasilnya menunjukkan: 15 unit; tiga kolam bila disaring; D2 ditemukan (412,5 kg); X9 menghasilkan 404 dengan pesan jelas; sektor empang menghasilkan 422, galat validasi otomatis karena bukan salah satu isi Literal; dan FastAPI membuatkan dokumentasi interaktif (/docs) dan skema (/openapi.json) tanpa Anda menulisnya.

Menyajikan aplikasi kepada pengguna

Sejauh ini semuanya berjalan di 127.0.0.1, alamat yang hanya bisa dijangkau komputer ini. Agar orang lain memakainya, aplikasi harus berjalan di mesin yang bisa dijangkau mereka. Mari lihat server sungguhan pertama, uvicorn, yang menjalankan API kita lewat HTTP asli:

08_penyajian_lokal.py
# Bab 43 - Menyajikan aplikasi: server sungguhan (uvicorn) di komputer sendiri + berkas penyajian
import socket
import threading
import time
from pathlib import Path

import httpx
import uvicorn

from api_unit import app

# Cari port yang sedang kosong, lalu nyalakan server di thread terpisah
with socket.socket() as s:
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]

server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
while not server.started:
    time.sleep(0.05)

try:
    jawab = httpx.get(f"http://127.0.0.1:{port}/ringkasan", timeout=5)
    print("lewat HTTP sungguhan:", jawab.status_code, "|", len(jawab.json()), "sektor")
finally:
    server.should_exit = True          # minta server berhenti dengan rapi
    thread.join(timeout=10)
print("server sudah berhenti:", not thread.is_alive())

# Berkas yang biasanya menyertai aplikasi saat dipindah ke server lain
keluar = Path(__file__).parent / "_tmp" / "penyajian"
keluar.mkdir(parents=True, exist_ok=True)
(keluar / "requirements.txt").write_text("fastapi\nuvicorn\nstreamlit\npandas\nfolium\n", encoding="utf-8")
perintah = {
    "API (FastAPI)": "uvicorn api_unit:app --host 127.0.0.1 --port 8000",
    "Dasbor (Streamlit)": "streamlit run app_streamlit.py",
    "Demo (Gradio)": "python app_gradio.py",
    "Dasbor (Dash)": "python app_dash.py",
    "Web ringan (Flask)": "flask --app app_htmx run",
}
print("\nPerintah menyalakan tiap aplikasi (dijalankan SENDIRI di terminal, tidak di sini):")
for nama, p in perintah.items():
    print(f"  {nama:20} {p}")
lewat HTTP sungguhan: 200 | 5 sektor
server sudah berhenti: True

Perintah menyalakan tiap aplikasi (dijalankan SENDIRI di terminal, tidak di sini):
  API (FastAPI)        uvicorn api_unit:app --host 127.0.0.1 --port 8000
  Dasbor (Streamlit)   streamlit run app_streamlit.py
  Demo (Gradio)        python app_gradio.py
  Dasbor (Dash)        python app_dash.py
  Web ringan (Flask)   flask --app app_htmx run

Server dinyalakan di port yang sedang kosong, dijawab 200 dengan 5 sektor, lalu dihentikan dengan rapi. Daftar di bawahnya adalah perintah penyalaan tiap aplikasi di bab ini, yang Anda jalankan sendiri di terminal; tidak ada yang dijalankan di sini.

Bagaimana memindahkannya agar orang lain bisa memakai? Berikut pilihan yang disebut dokumentasi resmi masing-masing. Kami tidak mendaftar, mengunggah, atau mencoba satu pun; syarat, harga, dan batasan bisa berubah, jadi baca halaman resminya sebelum memutuskan.

Aplikasi Pilihan menurut dokumentasi
Streamlit Streamlit Community Cloud (dijelaskan sebagai hosting gratis untuk aplikasi nonkomersial, pribadi, dan pendidikan), Snowflake, atau platform lain lewat tutorial
Gradio share=True (tautan sementara dari komputer Anda), Hugging Face Spaces (tautan permanen), atau dipasang pada aplikasi FastAPI milik sendiri dengan gr.mount_gradio_app()
Dash Plotly Cloud (terkelola) atau Dash Enterprise (untuk organisasi)
FastAPI, Flask server milik sendiri atau sewaan, menjalankan uvicorn seperti di atas

Catatan dari dokumentasi Hugging Face yang perlu dibaca teliti: saat ditulis, ruang (Space) bertipe Gradio atau Docker butuh paket berbayar untuk dibuat, sedangkan ruang statis gratis, dan ruang di perangkat keras gratis akan “tidur” bila lama tak dipakai. Dokumentasinya juga menyarankan menaruh rahasia (kunci, token) sebagai secret pengaturan, bukan menuliskannya di kode.

PeringatanAWAS

Sebelum menyajikan apa pun ke publik, tanya diri Anda: (1) apakah datanya boleh dilihat semua orang? Dasbor tanpa kata sandi terbuka bagi siapa pun yang punya alamatnya; (2) apakah ada rahasia di dalam kode? Kunci dan kata sandi tidak boleh ada di kode atau repositori; (3) siapa yang merawatnya bila rusak? Bab-bab Seri A1 membahas keamanan dan penerapan (deployment) secara serius.

Proyek Bersambung I5 (penutup): satu data, banyak pintu

Proyek Seri I5 kini lengkap: data unit usaha dibaca oleh satu modul, lalu dibuka lewat banyak pintu.

Proyek I5: satu data, banyak pintu.
  • API FastAPI (bab ini) untuk program lain.
  • Dasbor Streamlit per sektor (bab ini) untuk manusia.
  • CLI unit (Bab 42) untuk terminal dan otomasi.
  • Laporan mingguan dengan scraping yang sopan (Bab 41).
  • Demo di peramban tanpa server (Bab 37), dan peta (Bab 38 sampai 40).

Karena semua pintu membaca data yang sama, angkanya harus sama. Skrip penutup memeriksa itu: rata-rata per sektor dihitung langsung dari modul, lewat API, dan lewat dasbor, lalu dibandingkan.

09_proyek_penutup.py
# Bab 43 - Proyek bersambung I5 (penutup): satu data, tiga jalur akses, hasilnya harus sama
import logging

from fastapi.testclient import TestClient
from streamlit.testing.v1 import AppTest

from api_unit import app
from data_unit import baca_unit, ringkas_per_sektor

logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(logging.ERROR)

langsung = ringkas_per_sektor(baca_unit())                        # jalur 1: modul data
lewat_api = {r["sektor"]: r for r in TestClient(app).get("/ringkasan").json()}   # jalur 2: API

dasbor = AppTest.from_file("app_streamlit.py", default_timeout=30).run()
print(f"{'sektor':8} {'unit':>4} {'rata-rata (modul)':>18} {'API':>8} {'dasbor':>8}  cocok")
semua_cocok = True
for sektor, r in langsung.items():
    dasbor.selectbox[0].select(sektor).run()                      # jalur 3: dasbor Streamlit
    metrik = {m.label: m.value for m in dasbor.metric}
    cocok = (f"{r['rata']:.1f}" == f"{lewat_api[sektor]['rata']:.1f}" == metrik["Rata-rata nilai"]
             and str(r["jumlah"]) == metrik["Jumlah unit"])
    semua_cocok &= cocok
    print(f"{sektor:8} {r['jumlah']:>4} {r['rata']:>18.1f} {lewat_api[sektor]['rata']:>8.1f} "
          f"{metrik['Rata-rata nilai']:>8}  {'ya' if cocok else 'TIDAK'}")

print("\nsemua jalur konsisten:", semua_cocok)
sektor   unit  rata-rata (modul)      API   dasbor  cocok
hutan       3               35.2     35.2     35.2  ya
kebun       4              257.5    257.5    257.5  ya
sawah       2             1115.2   1115.2   1115.2  ya
kolam       3              369.3    369.3    369.3  ya
kandang     3              383.8    383.8    383.8  ya

semua jalur konsisten: True

Kelima sektor cocok di ketiga jalur (kolom terakhir “ya”, dan semua jalur konsisten: True). Pemeriksaan semacam ini adalah tes yang murah dan menyelamatkan: bila suatu hari satu jalur mulai menghitung lain, Anda tahu sebelum pengguna tahu.

Latihan

  1. Pada app_streamlit.py, tambahkan st.slider “Nilai minimal” yang menyaring tabel. Tambahkan pemeriksaan di 02_streamlit_interaksi.py yang menggeser slider dan membaca jumlah baris tabel.
  2. Pada api_unit.py, tambahkan titik akses GET /sektor yang mengembalikan daftar nama sektor, lalu uji dengan TestClient.
  3. Pada app_dash.py, tambahkan komponen kedua (misalnya dcc.RadioItems untuk memilih urutan naik atau turun) dan callback-nya. Uji lewat klien uji Flask.
  4. Tantangan: buat app_gradio.py menerima nomor unit (D1, L2, …) dan mengembalikan unit itu dengan memanggil API FastAPI Anda lewat TestClient (jangan lewat jaringan). Tampilkan galat yang ramah bila nomornya tidak ada.

Ringkasan Bab

  • Streamlit: skrip dari atas ke bawah, dijalankan ulang tiap interaksi; uji dengan AppTest tanpa peramban.
  • Gradio: membungkus fungsi/model dengan kotak otomatis; uji fungsinya langsung atau lewat gradio_client. Jangan pakai share=True untuk data yang bukan untuk umum.
  • Dash: tata letak + callback, berdiri di atas Flask; uji dengan klien uji.
  • folium dan pydeck menghasilkan peta HTML interaktif yang memuat pustakanya dari internet saat dibuka.
  • HTMX + Flask: server mengirim potongan HTML; yang teruji di sini hanya sisi server.
  • FastAPI: API JSON dengan validasi dan dokumentasi otomatis; uji dengan TestClient.
  • Menyajikan = menjalankan di mesin yang bisa dijangkau pengguna; pertimbangkan data, rahasia, dan perawatan lebih dulu.

Penutup Seri I5

Selamat! Anda menyelesaikan Seri I5, dan itu berarti Tingkat Intermediate (Jilid 2) tamat. Dari kode yang rapi dan terawat (Seri I1), data dan internet (I2), statistik dan pemodelan (I3), bahasa Python tingkat lanjut (I4), sampai aplikasi pertama (I5), Anda sekarang bisa:

  • menulis kode yang rapi, diuji, dan terkontrol versinya;
  • mengambil, membersihkan, dan menganalisis data, termasuk statistik dasar;
  • memakai generator, decorator, type hint, Pydantic, dan async;
  • membuat API web, memproses data spasial, dan menampilkannya di peta;
  • mengotomasi tugas berulang, membuat alat baris perintah, dan menyajikan data lewat dasbor.

Tetapi aplikasi Anda masih berjalan di komputer sendiri. Agar ia cepat, aman, bisa di-server untuk banyak pengguna, menangani data besar, dan cerdas, Anda perlu masuk ke Tingkat Advanced: rekayasa perangkat lunak profesional, data skala besar, machine learning, sampai AI.

Selanjutnya, di Bab 44: performa. Kita belajar mengukur dan mempercepat kode (profiling, struktur data yang tepat, vektorisasi, dan caching).