> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livingsecurity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Power BI

> Hubungkan Power BI ke Living Security Public API dengan Power Query, bangun model star-schema pada /v1, dan pilih dataset operasional atau pelaporan untuk phishing.

Panduan ini merupakan contoh menyeluruh untuk membaca `/v1` ke dalam Power BI. Panduan ini mencakup
pola konektivitas Power Query (M), model semantik star-schema yang dibangun
di atas `/v1/activities`, penyegaran inkremental, dan kapan harus menggunakan dataset
pratinjau **pelaporan** untuk hasil phishing per orang.

Contoh menggunakan host produksi AS
(`https://api.us-east-1.livingsecurity.ai/v1`). Untuk organisasi di UE, gunakan
`https://api.eu-west-1.livingsecurity.ai/v1` pada `Fn_FetchAll` dan
`Fn_GetJson`, lalu ikat kredensial sumber data ke host tersebut. Lihat
[Wilayah](/id/developers/v1/regions).

Panduan ini menggunakan kredensial Basic tersimpan dengan kunci API. Anda tidak memerlukan
klien OAuth. Lihat [Autentikasi](/id/developers/v1/authentication) untuk pilihan
kredensial, dan [Developer Hub](/id/developers/developer-hub/overview)
untuk akses dan penyiapan.

## Kegunaan contoh ini

Gunakan contoh ini apabila Anda perlu:

* Memuat endpoint daftar `/v1` ke dalam dataset Power BI dengan paginasi
  kursor yang benar dan percobaan ulang saat terkena batas laju.
* Membangun model semantik yang ukurannya ("entitas aktif", "aktivitas per
  entitas aktif") bersifat dimensional, bukan dihitung dari satu tabel datar.
* Mengubah penyegaran terjadwal Power BI dari penelusuran penuh seluruh riwayat menjadi
  jendela harian yang kecil menggunakan penyegaran inkremental.
* Memilih granularitas phishing yang tepat: total kampanye (operasional) dibandingkan
  hasil per orang dan peristiwa bertipe (pratinjau pelaporan).

## Prasyarat

* Kunci API dengan izin yang dibutuhkan oleh kueri Anda:
  * Contoh operasional: `activity:read`, `entity:read`, dan
    `simulation:read`.
  * Phishing per orang melalui dataset pelaporan: `reporting:read`.
    Lihat [Kunci API](/id/developers/developer-hub/api-keys).
* Power BI Desktop, atau ruang kerja Power BI / Fabric tempat Anda dapat membuat
  model semantik dan menjadwalkan penyegaran.

## Pilih granularitas yang tepat

`/v1` menyediakan dua permukaan terkait untuk phishing. Keduanya tidak dapat dipertukarkan.

| Kebutuhan | Gunakan | Izin | Granularitas |
| - | - | - | - |
| Total funnel kampanye | `GET /v1/simulations/{id}/results` | `simulation:read` | Satu baris per simulasi (terkirim, dibuka, diklik, …) |
| Hasil per orang (operasional) | `GET /v1/simulation_results` | `simulation:read` | Satu baris per entitas × simulasi |
| Hasil per orang (BI / pelaporan) | `GET /v1/reporting/simulation_results` | `reporting:read` | Granularitas sama, nama kolom BI (`entity_id`, `simulation_id`), watermark `updated_after` |
| Fakta interaksi bertipe | `GET /v1/reporting/simulation_events` | `reporting:read` | Satu fakta per interaksi (`simulation.link_clicked`, …), `occurred_after` |
| Dimensi Tenaga Kerja saat ini | `GET /v1/reporting/entities` | `reporting:read` | Satu baris saat ini per entitas. Gabungkan `reporting.simulation_results.entity_id = reporting.entities.id` |
| Versi entitas saat ini | `GET /v1/reporting/entity_versions` | `reporting:read` | Satu interval saat ini tanpa batas akhir per entitas. Tidak ada tabel riwayat versi |
| Keanggotaan Kelompok saat ini | `GET /v1/reporting/cohort_memberships` | `reporting:read` | Satu fakta saat ini per `(cohort_id, entity_id)`. Tidak ada tabel riwayat keanggotaan |
| Join spine untuk semua jenis aktivitas | `GET /v1/activities` | `activity:read` | Satu peristiwa per aktivitas, `participants[]` bersarang |

Kueri contoh Power BI di bawah ini memanggil rute **operasional**
(`/v1/simulations`, `/v1/simulations/{id}/results`, `/v1/activities`,
`/v1/entities`). Untuk pelaporan phishing per orang, utamakan keluarga
pratinjau **pelaporan** di bawah. Kunci yang hanya diberi `reporting:read` tidak dapat
memanggil `/v1/simulation_results` operasional (API mengembalikan `403`).

### Skala tingkat kesulitan

Tingkat kesulitan simulasi adalah bilangan bulat 1–5 pada `/v1/simulations` (`difficulty_level`),
`/v1/simulation_results` (`difficulty_level`), properti `/v1/activities`
(`difficulty_tier`), dan `/v1/reporting/simulation_events` (`difficulty_tier`).
`1` adalah yang paling mudah dan `5` yang paling sulit. Simulasi adaptif dan progresif tidak memiliki
satu nilai pada tingkat kampanye, sehingga `/v1/simulations.difficulty_level` bernilai `null`
sementara `difficulty_level` per penerima pada hasil tetap dapat diatur. Untuk memetakan ke
ekspor lama berupa rendah/sedang/tinggi, anggap `1–2` sebagai rendah, `3` sebagai sedang, dan `4–5` sebagai
tinggi.

<Warning>
  Jangan membangun integrasi BI baru berdasarkan panggilan jaringan Dasbor yang tidak terdokumentasi
  atau ikhtisar Public API `/api/v1` kurasi lama. Lakukan autentikasi ke
  host `/v1` regional organisasi dengan kunci API Developer Hub. Lihat
  [Wilayah](/id/developers/v1/regions).
</Warning>

## Pola konektivitas (Power Query)

Empat aturan mendasari setiap kueri dalam contoh ini. Masing-masing diberlakukan oleh
Power BI Service, bukan sekadar preferensi gaya.

<AccordionGroup>
  <Accordion title="Pertahankan URL dasar Web.Contents tetap statis dan akhiri di /v1">
    Letakkan semua yang dinamis di `RelativePath` dan `Query`. Argumen pertama untuk
    `Web.Contents` harus berupa string literal yang berakhir pada root
    `/v1` regional organisasi:

    * AS: `https://api.us-east-1.livingsecurity.ai/v1`
    * UE: `https://api.eu-west-1.livingsecurity.ai/v1`

    Pemanggil kemudian hanya meneruskan sumber dayanya (`"simulations"`, bukan
    `"v1/simulations"`). Jangan menempelkan host AS untuk organisasi UE.

    Jika argumen pertama dibentuk melalui penggabungan string, Power BI Service
    tidak dapat mengidentifikasi sumber data secara statis dan penyegaran terjadwal gagal dengan
    galat "dynamic data source", meskipun kueri berfungsi di Power BI
    Desktop.
  </Accordion>

  <Accordion title="Berikan kunci API sebagai kredensial Basic tersimpan, bukan dalam teks kueri">
    Saat Power BI meminta kredensial untuk sumber data `/v1`, pilih
    **Basic** dan tempelkan kunci API sebagai **nama pengguna**. Isi kolom **Kata Sandi**
    dengan placeholder seperti `x`. Power BI Service menolak
    kata sandi kosong meskipun API mengabaikan kolom tersebut. Kunci sebagai kata sandi dengan
    nama pengguna placeholder juga berfungsi.

    Jangan menambahkan header `Authorization` di M. Cara itu berfungsi di desktop Anda, tetapi
    menanamkan kunci ke dalam berkas kueri dan bermasalah dengan penyegaran terjadwal.
  </Accordion>

  <Accordion title="Biarkan uji koneksi pada dialog kredensial berjalan">
    `GET /v1` memerlukan autentikasi: endpoint ini mengembalikan `200` dengan kunci yang valid dan `401`
    tanpa kunci. Arahkan sumber data ke `…/v1` dan **jangan** lewati pengujian —
    kunci yang benar akan tersimpan, sedangkan kunci yang dicabut atau salah format akan langsung gagal.

    Contoh lama menggunakan host saja sebagai sumber data dan menyuruh Anda melewati
    pengujian karena `GET /` selalu `404`. Arahkan ulang model tersebut ke `…/v1`.
  </Accordion>

  <Accordion title="Atur limit=500 secara eksplisit pada setiap kueri daftar">
    Ukuran halaman default pada setiap endpoint daftar `/v1` adalah 25. Membiarkannya default
    melipatgandakan jumlah permintaan untuk data yang sama. Nilai kueri harus berupa teks —
    `limit = "500"` di M, bukan `limit = 500`.
  </Accordion>
</AccordionGroup>

### Fungsi paginasi bersama

Setiap kueri dalam contoh ini memanggil satu `Fn_FetchAll` yang menelusuri
`next_page_token` hingga bernilai `null` (lihat
[paginasi](/id/developers/v1/pagination)). Properti pentingnya:

* **URL dasar `/v1` statis, `RelativePath` dan `Query` dinamis.** Diperlukan untuk
  penyegaran terjadwal dan untuk uji kredensial.
* **`ManualStatusHandling = {429}` dengan percobaan ulang terbatas.** Fungsi ini membaca
  `Retry-After` saat Power Query menyediakannya, jika tidak menunggu 30 detik, dan
  menyerah setelah 5 respons `429` berturut-turut dengan galat eksplisit, alih-alih
  berulang hingga penyegaran habis waktu. `5xx` sengaja tidak
  dicoba ulang — galat server harus terlihat, bukan diam-diam menghasilkan dataset
  parsial.
* **`Timeout` 60 detik.** Halaman biasanya merespons dalam waktu jauh kurang dari satu detik, sehingga
  satu menit tanpa respons berarti permintaan tersebut gagal.
* **`Binary.Buffer` sebelum `Json.Document`.** Tanpa buffering, mesin mashup
  dapat meminta ulang aliran respons jika membacanya lebih dari sekali.
* **`baseQuery` diteruskan tanpa perubahan pada setiap iterasi.** Token halaman adalah
  kursor buram yang ditandatangani dan terikat pada organisasi, kredensial, versi kontrak,
  serta sidik jari filter dan pengurutan. Mengubah filter
  di tengah penelusuran ditolak sebagai token tidak valid, bukan dimulai ulang dari halaman
  pertama.

```m Fn_FetchAll.pq — base and call shape theme={null}
let
    // Literal /v1 root. Callers pass the resource alone ("simulations").
    // US: "https://api.us-east-1.livingsecurity.ai/v1"
    BaseUrl = "https://api.eu-west-1.livingsecurity.ai/v1",

    Fn_FetchAll = (relativePath as text, baseQuery as record) as list =>
        // Walk next_page_token until null. Full pagination + 429 retry lives
        // in examples/powerbi/queries/Fn_FetchAll.pq.
        ...
in
    Fn_FetchAll
```

### Kueri bertipe per endpoint

Setiap konsumen `Fn_FetchAll` mengikuti bentuk yang sama: panggil endpoint,
proyeksikan daftar kolom tetap, hapus diskriminator `object`, dan beri tipe
stempel waktu sebagai `datetimezone` agar ukuran tanggal tidak bergeser secara diam-diam akibat
zona waktu kapasitas penyegaran.

<CodeGroup>
  ```m Simulations.pq (GET /v1/simulations) theme={null}
  let
      Source = Fn_FetchAll("simulations", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          {
              "id", "object", "name", "description", "status", "vectors",
              "difficulty_level", "difficulty_mode",
              "started_at", "completed_at", "created_at", "updated_at"
          },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Flattened = Table.TransformColumns(
          Dropped,
          {
              {
                  "vectors",
                  each if _ = null then null
                       else Text.Combine(List.Transform(_, Text.From), ", "),
                  type text
              }
          }),

      Typed = Table.TransformColumnTypes(
          Flattened,
          {
              { "id",           type text },
              { "name",         type text },
              { "description",  type text },
              { "status",       type text },
              { "started_at",   type datetimezone },
              { "completed_at", type datetimezone },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```

  ```m Entities.pq (GET /v1/entities) theme={null}
  let
      Source = Fn_FetchAll("entities", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          { "id", "object", "type", "status", "display_name", "email",
            "created_at", "updated_at" },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Typed = Table.TransformColumnTypes(
          Dropped,
          {
              { "id",           type text },
              { "type",         type text },
              { "status",       type text },
              { "display_name", type text },
              { "email",        type text },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```
</CodeGroup>

<Tip>
  Daftar kolom eksplisit pada `Table.FromRecords` menjaga skema tetap stabil ketika
  endpoint mengembalikan nol baris. `Table.FromRecords({})` polos menghasilkan
  tabel tanpa kolom dan setiap langkah berikutnya gagal karena kolom yang hilang.
</Tip>

## Excel

Power Query (M) yang sama berjalan di Get & Transform pada Excel — mesinnya sama
dengan Power BI Desktop. Gunakan ini jika Anda menginginkan tabel lembar kerja
alih-alih model semantik.

Pada Excel untuk Windows (Microsoft 365, Excel 2024, 2021, 2019, atau 2016):

1. **Data → Get Data → From Other Sources → Blank Query.**
2. Di Power Query Editor, **Home → Advanced Editor**. Tempelkan
   `Fn_FetchAll` terlebih dahulu, lalu ganti nama kueri tersebut menjadi `Fn_FetchAll` (Query
   Settings → Name, atau klik kanan di panel Queries → **Rename**).
3. Ulangi untuk `Fn_GetJson`, lalu untuk setiap kueri laporan.
4. **Home → Close & Load → Close & Load To…** membuka dialog **Import Data**.
   Pilih **Only Create Connection** untuk `Fn_FetchAll` dan
   `Fn_GetJson` (keduanya adalah fungsi, bukan tabel). Pilih **Table** untuk
   setiap kueri laporan yang Anda inginkan di lembar kerja.
5. Pada penyegaran pertama, pilih **Basic**: kunci API sebagai **User name**, placeholder
   seperti `x` sebagai **Password**. Excel menyimpan satu kredensial
   per URL sumber data; kueri berikutnya yang mengakses host `/v1` yang sama akan menggunakannya kembali.

<Note>
  **Close & Load To…** dapat tampak redup saat pertama kali Anda membuat kueri di
  sebuah buku kerja. Gunakan **Close & Load**, lalu di Excel pilih **Data →
  Queries & Connections**, klik kanan kueri, dan pilih **Load To**.
  Jalur tersebut didokumentasikan oleh Microsoft untuk kasus ini.
</Note>

Anda tidak dapat mengirim `Living-Security-Version` dari Power Query pada
sumber yang terautentikasi — pembatasan yang sama seperti pada Power BI. Sematkan akun layanan
atau teruskan `api_version` dalam record kueri. Lihat
[Versi kontrak dan kredensial tersimpan](#versi-kontrak-dan-kredensial-tersimpan).

Kueri laporan siap pakai (pemetaan kolom dan perbedaan dari
ekspor penyedia sebelumnya):

<CardGroup cols={3}>
  <Card title="Kepatuhan per orang" href="/id/developers/v1/reports/per-person-compliance">
    Pendaftaran Pelatihan pada granularitas orang × penugasan.
  </Card>

  <Card title="Log phishing email" href="/id/developers/v1/reports/email-phishing-log">
    Interaksi phishing tingkat peristiwa, 12 bulan terakhir.
  </Card>

  <Card title="Pengguna paling rentan" href="/id/developers/v1/reports/most-susceptible-users">
    Rekapitulasi klik, kredensial, dan pelaporan per orang selama 12 bulan.
  </Card>
</CardGroup>

## Dataset pratinjau pelaporan (phishing per orang)

Keluarga pratinjau pelaporan menyediakan dataset untuk phishing per orang.
Keluarga ini memerlukan `reporting:read` dan akses pelaporan untuk organisasi Anda.
Gunakan katalog dataset di bawah untuk memeriksa skema dan kebijakan datanya.

Temukan katalog langsung:

```text theme={null}
GET /v1/reporting/datasets
GET /v1/reporting/datasets/simulation_results
GET /v1/reporting/datasets/simulation_events
GET /v1/reporting/datasets/entities
GET /v1/reporting/datasets/entity_versions
GET /v1/reporting/datasets/cohort_memberships
```

Setiap dataset mendeklarasikan granularitas, kunci primer, join, watermark, koreksi, dan
kebijakan penghapusan.

### simulation\_results

* **Path:** `GET /v1/reporting/simulation_results`
* **Granularitas:** entitas × simulasi
* **Filter:** `simulation`, `entity`, `updated_after`, ditambah `limit` /
  `page_token`
* **Objek wire:** `reporting.simulation_result`
* **Kunci join:** `entity_id`, `simulation_id`

```m ReportingSimulationResults.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_results",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id", "status",
            "targeted_at", "delivered_at", "opened_at", "clicked_at",
            "credential_submitted_at", "reported_at",
            "email_subject", "sender_email", "difficulty_level",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "status",        type text },
            { "targeted_at",   type datetimezone },
            { "updated_at",    type datetimezone }
        })
in
    Typed
```

Gunakan `updated_after` sebagai watermark batas bawah inklusif saat Anda menyegarkan
partisi terbaru. Dimensi hasil yang dapat berubah dinyatakan ulang di tempat dengan `id` yang sama.

### simulation\_events

* **Path:** `GET /v1/reporting/simulation_events`
* **Granularitas:** satu fakta bertipe per interaksi yang teramati
* **Filter:** `simulation`, `entity`, `event_type`, `occurred_after`, ditambah
  `limit` / `page_token`
* **Objek wire:** `reporting.simulation_event`
* **Jenis peristiwa:** token publik tertutup seperti `simulation.delivered`,
  `simulation.opened`, `simulation.link_clicked`,
  `simulation.credentials_submitted`, `simulation.reported` — bukan jenis aktivitas `phishing.simulation.*`
  ClickHouse mentah
* **Perangkat:** `device_type` adalah kelas turunan (token yang teramati meliputi
  `desktop`, `mobile`, `tablet`, `unknown`). `user_agent` mentah tidak ada pada
  `/v1`. IP mentah tidak pernah ada pada `/v1` (tidak pernah disimpan; hanya `ip_hash` dengan salt).

```m ReportingSimulationEvents.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_events",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id",
            "event_type", "occurred_at",
            "email_subject", "sender_email", "sender_domain", "difficulty_tier",
            "device_type",
            "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "event_type",    type text },
            { "occurred_at",   type datetimezone }
        })
in
    Typed
```

Gunakan `occurred_after` untuk pemuatan peristiwa inkremental. Fakta bersifat hanya-tambah;
data yang terlambat tiba menjadi baris baru.

### entities

* **Path:** `GET /v1/reporting/entities`
* **Granularitas:** satu baris saat ini per entitas
* **Filter:** `type`, `updated_after`, ditambah `limit` / `page_token`
* **Objek wire:** `reporting.entity`
* **Kunci join:** `id` / `entity_id` (nilai yang sama)

Gabungkan fakta phishing dengan orang menggunakan
`reporting.simulation_results.entity_id = reporting.entities.id`.
`GET /v1/entities` operasional tetap berada di `entity:read` dan merupakan izin yang
berbeda.

```m ReportingEntities.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entities", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status",
            "display_name", "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",          type text },
            { "entity_id",   type text },
            { "type",        type text },
            { "status",      type text },
            { "display_name", type text },
            { "created_at",  type datetimezone },
            { "updated_at",  type datetimezone }
        })
in
    Typed
```

### entity\_versions dan cohort\_memberships

Kedua dataset ini memiliki pengecualian bernama yang hanya mencakup data saat ini:

> Tidak ada tabel riwayat versi atau keanggotaan yang otoritatif. Dataset ini memproyeksikan baris saat ini sebagai satu interval tanpa batas akhir (`valid_to` bernilai null, `is_current` bernilai true). `valid_from` adalah `created_at` atau jika tidak ada `updated_at` untuk versi entitas, dan waktu observasi permintaan untuk keanggotaan Kelompok. Interval historis yang telah ditutup tidak dibuat-buat.

* **entity\_versions:** `GET /v1/reporting/entity_versions`. Filter sama dengan
  entities. `valid_from` adalah `created_at` atau jika tidak ada `updated_at`. Entitas
  dengan kedua stempel waktu null dihilangkan.
* **cohort\_memberships:** `GET /v1/reporting/cohort_memberships`. Filter:
  `cohort`, `entity`, `updated_after`. `updated_at` adalah pembaruan terakhir Kelompok
  agar penyegaran inkremental tetap akurat. `valid_from` adalah waktu
  observasi permintaan. Gabungkan `entity_id` ke `reporting.entities.id`.

```m ReportingEntityVersions.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entity_versions", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status", "display_name",
            "valid_from", "valid_to", "is_current",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical }
        })
in
    Typed
```

```m ReportingCohortMemberships.pq theme={null}
let
    Source = Fn_FetchAll("reporting/cohort_memberships", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "cohort_id", "entity_id",
            "valid_from", "valid_to", "is_current",
            "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "cohort_id",  type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical },
            { "updated_at", type datetimezone }
        })
in
    Typed
```

## Model semantik star-schema pada /v1/activities

`/v1/activities` adalah aliran peristiwa berstempel waktu milik organisasi. Aliran ini tidak memiliki
ukuran numerik — setiap kuantitas dalam laporan adalah jumlah baris — sehingga
star schema sangat cocok.

```text theme={null}
Dates (dimension)
      │  1
      ▼  ∗
  Activities (fact)   1  ∗  ActivityParticipants (bridge)   ∗  1   Entities (dimension)
```

* **`Activities`** adalah fakta peristiwa pada granularitas sebenarnya. Satu baris per aktivitas.
  Partisipan dihilangkan di sini dan dibangun ulang sebagai tabelnya sendiri.
* **`ActivityParticipants`** ada karena partisipan memang bersifat
  satu-ke-banyak, dengan peran seperti `actor` dan `target`. Meratakannya ke
  `Activities` akan menggandakan baris aktivitas (menggelembungkan setiap hitungan)
  atau membuang partisipan tambahan.
  Relasi ke `Activities` adalah satu-satunya relasi dua arah dalam model,
  sehingga slicer entitas menjangkau tabel fakta.
* **`Entities`** adalah dimensi yang memberi nama partisipan. Baris partisipan
  pada suatu aktivitas memuat `entity_id` dan peran, tetapi tidak memuat nama tampilan.
* **`Dates`** dihasilkan di M (nol panggilan API) dan ditandai sebagai tabel
  tanggal model. Hal inilah yang membuat time intelligence DAX
  (`DATESINPERIOD`, `DATEADD`) sah digunakan. Kolom nama bulan dan nama hari dalam seminggu mendeklarasikan
  `sortByColumn` pada pasangan numeriknya agar visual tidak mengurutkan bulan
  secara alfabetis.

Semua ukuran bersifat eksplisit (model menetapkan `discourageImplicitMeasures`): total
aktivitas, entitas aktif, aktivitas per entitas aktif, hari dengan aktivitas,
dan rata-rata harian 7 hari terakhir.

### Bentuk tabel bridge

`ActivityParticipants` menelusuri `/v1/activities` dan memecah array `participants`
menjadi satu baris per partisipan:

```m ActivityParticipants.pq theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Selected = Table.SelectColumns(AsTable, { "id", "event_ts", "participants" }),
    Renamed  = Table.RenameColumns(Selected, { { "id", "activity_id" } }),
    Expanded = Table.ExpandListColumn(Renamed, "participants"),
    NonNull  = Table.SelectRows(Expanded, each [participants] <> null),
    Fields   = Table.ExpandRecordColumn(
        NonNull, "participants",
        { "role", "entity_id", "display_name" }),

    Typed = Table.TransformColumnTypes(
        Fields,
        {
            { "activity_id",  type text },
            { "event_ts",     type datetimezone },
            { "role",         type text },
            { "entity_id",    type text },
            { "display_name", type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd)
in
    Filtered
```

`ActivityParticipants` menelusuri `/v1/activities` secara independen alih-alih
mereferensikan kueri `Activities`. Di Power BI, dua kueri yang dimuat sebagai dua
tabel masing-masing mengambil data secara independen saat penyegaran. Menjaganya tetap independen
memungkinkan layanan menyegarkan partisi kedua tabel secara paralel.

## Penyegaran inkremental

Kedua tabel granularitas fakta (`Activities` dan `ActivityParticipants`) mendeklarasikan
kebijakan penyegaran inkremental Power BI: menyimpan partisi tahunan selama 5 tahun,
dan mengimpor ulang 14 hari terakhir pada setiap penyegaran.

Mekanismenya adalah sepasang parameter DateTime, `RangeStart` dan `RangeEnd`,
yang diikat ulang oleh layanan Power BI per partisi saat penyegaran. Setiap kueri fakta
menyertakan parameter tersebut ke dalam filter `start_date` dan `end_date`
API, sehingga volume permintaan tingkat partisi sebanding dengan partisi,
bukan dengan total riwayat. Kueri melebarkan jendela API satu menit pada setiap
sisi dan menerapkan ulang batas `[RangeStart, RangeEnd)` setengah terbuka yang tepat
secara lokal, sehingga ketepatan partisi tidak bergantung pada apakah server
memperlakukan batasnya sebagai inklusif atau eksklusif.

```m Activities.pq — the fold that makes incremental refresh work theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object", "participants" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "activity_type", type text },
            { "event_ts",      type datetimezone },
            { "service_id",    type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd),

    WithDate = Table.AddColumn(
        Filtered, "event_date", each Date.From([event_ts]), type date)
in
    WithDate
```

Penyegaran pertama setelah deployment menerapkan kebijakan: pemuatan historis penuh,
yang lambat. Setiap penyegaran setelahnya hanya menyentuh jendela inkremental.

Diukur pada dataset berisi \~158.000 aktivitas dan \~2.300 entitas:

| Penyegaran | Waktu nyata |
| - | - |
| Pertama (penerapan kebijakan, pemuatan historis penuh) | \~31 mnt |
| Setiap penyegaran setelahnya | **\~15 dtk** |

<Warning>
  Endpoint REST penyegaran klasik Power BI (`POST .../refreshes` dengan hanya
  `notifyOption`) secara diam-diam mengabaikan kebijakan penyegaran dan melakukan penelusuran penuh setiap
  kali. Untuk menerapkan kebijakan melalui REST API, gunakan enhanced refresh: kirim
  `type`, `commitMode`, dan `applyRefreshPolicy` dalam body. Penyegaran yang
  dipicu oleh penjadwal Power BI Service selalu menerapkan kebijakan — keanehan ini khusus untuk panggilan REST.
</Warning>

<Note>
  Jendela 14 hari sengaja dibuat longgar. Ingesti aktivitas tidak bersifat
  hanya-tambah-pada-saat-ini: backfill konektor memasukkan peristiwa dengan stempel waktu masa lalu.
  Jendela 1-2 hari akan melewatkannya secara permanen.
</Note>

## Memperluas jendela penyegaran ke dalam API

`GET /v1/activities` menerima filter `start_date` dan `end_date` yang menjadi tujuan
parameter Power BI, dengan memfilter `event_ts` di antara batas-batas tersebut.
`Entities` adalah dimensi, sehingga contoh ini menyegarkannya secara penuh setiap kali
alih-alih secara inkremental.

Dataset pelaporan menggunakan watermark-nya sendiri (`updated_after`,
`occurred_after`) alih-alih jendela tanggal aktivitas.

## Batas laju selama penyegaran

`/v1` menerapkan anggaran terpisah per kunci dan per IP (lihat
[batas laju](/id/developers/v1/rate-limits)). Patuhi `429` + `Retry-After`; jangan
menanamkan anggaran permintaan secara permanen ke dalam model Anda. Dua properti penyegaran
Power BI yang penting:

* **Penelusuran satu tabel bersifat sekuensial.** Setiap permintaan halaman memerlukan
  token halaman sebelumnya, sehingga satu tabel dibatasi oleh latensi terlepas dari ukuran
  halaman.
* **Penyegaran tabel secara bersamaan saling melipatgandakan.** Beberapa tabel besar yang disegarkan
  secara paralel adalah kasus yang mencapai batas atas, itulah sebabnya jalur percobaan ulang 429
  di `Fn_FetchAll` penting. Utamakan lebih sedikit tabel paralel daripada
  mengasumsikan konkurensi tak terbatas.

Percobaan ulang telah diuji terhadap pembatas laju yang benar-benar jenuh. Penyegaran
yang berjalan saat beban kerja paralel sengaja menahan kunci pada batas atasnya
tetap selesai dan menghasilkan jumlah baris yang sama dengan baseline tanpa kontensi —
percobaan ulang mengembalikan halaman lengkap, bukan celah.

## Versi kontrak dan kredensial tersimpan

`/v1` menerima header permintaan `Living-Security-Version` untuk menyematkan versi
kontrak yang dipublikasikan (lihat [Panduan cepat API](/id/developers/v1/quickstart)). Power
Query tidak dapat mengirim header tersebut ketika sumber data menggunakan kredensial tersimpan —
hanya sumber data anonim yang mengizinkan header kustom. Menambahkannya menyebabkan penyegaran gagal
dengan:

```text theme={null}
Expression.Error: The 'Living-Security-Version' header is only supported when
connecting anonymously.
Microsoft.Data.Mashup.ErrorCode = 10348.
```

Simpan kunci dalam kredensial tersimpan Power BI dan jangan gunakan header kustom.
Tanpa versi eksplisit dalam permintaan, API menggunakan `contract_version` yang disematkan pada akun layanan,
atau versi terbaru yang dipublikasikan jika tidak ada penyematan.
Konfirmasikan versi kontrak akun tersebut kepada administrator API Anda sebelum
memublikasikan penyegaran. Lihat [changelog API](/id/developers/v1/changelog) untuk
versi yang didukung.

Jika penyegaran mulai berperilaku tidak terduga, periksa header respons
`Living-Security-Version` API untuk melihat kontrak mana yang sebenarnya
melayani permintaan.

## Segarkan dan verifikasi model Anda

Publikasikan model semantik ke ruang kerja Power BI Anda, ikat kredensial tersimpannya
ke sumber `/v1` regional, dan jalankan penyegaran sebelum mengaktifkan
jadwal. Tinjau riwayat penyegaran untuk melihat kegagalan atau pembacaan yang tidak lengkap.

Periksa jumlah baris terhadap pembacaan API independen dengan filter yang sama.
[Panduan paginasi](/id/developers/v1/pagination) menyertakan contoh Python dan cURL
untuk membaca kumpulan hasil yang lengkap. Penyegaran yang hanya membaca
halaman pertama dapat tampak berhasil pada dataset kecil; bandingkan jumlahnya sebelum
mengandalkan laporan.

## Pilihan model

* **Total kampanye dan hasil per orang memiliki granularitas yang berbeda.**
  `SimulationResults` memanggil `GET /v1/simulations/{id}/results`
  (satu GET per eksekusi). Untuk phishing per orang, tambahkan kueri untuk
  `GET /v1/reporting/simulation_results` dan
  `GET /v1/reporting/simulation_events` (atau `GET /v1/simulation_results`
  operasional jika Anda sudah memiliki `simulation:read`).
* **`vectors` pada `/v1/simulations` diratakan menjadi string yang digabung dengan koma.**
  Kolom bertipe daftar tidak dapat diiris sebagai dimensi sebenarnya. Jika halaman laporan
  perlu memfilter berdasarkan vektor, tambahkan tabel bridge simulasi-ke-vektor.

## Langkah berikutnya

<CardGroup cols={2}>
  <Card title="Developer Hub" href="/id/developers/developer-hub/overview">
    Buat akun layanan dan kunci API untuk Power BI.
  </Card>

  <Card title="Panduan cepat API" href="/id/developers/v1/quickstart">
    Autentikasi permintaan `/v1` dengan Basic auth.
  </Card>

  <Card title="Paginasi" href="/id/developers/v1/pagination">
    Envelope daftar, token kursor, dan stabilitas filter sepanjang penelusuran.
  </Card>

  <Card title="Batas laju" href="/id/developers/v1/rate-limits">
    Anggaran per kunci dan per IP, kontrak 429, dan panduan percobaan ulang.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.