0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

すぐ動くYellowfinの組み込みサンプル

0
Last updated at Posted at 2026-06-03

はじめに

Yellowfin を外部サイト(自社アプリや ISV 製品)に組み込みたい、というご相談をよくいただきます。
デモをお見せするだけなら作り込んだ環境があるのですが、「実際に動くサンプルが欲しい」と言われると、これがなかなか渡しにくいものです。理由はだいたい次の3つです。

  • データが特定のデモ環境にしかない
  • 中身が作り込まれていて、そのまま渡せない
  • 渡せたとしても、環境構築の手順が重い

そこでこの記事では、インストール直後(=素の状態)の Yellowfin に対して、
Yellowfin 本体とサンプルアプリの両方を Docker で起動できる、最小の埋め込みサンプルを作りました。
git clone や zip で渡せば、相手の環境で start.sh(Windows は start.bat)を1回叩くだけで動きます。

ソース一式は GitHub で公開しています。

:octocat: リポジトリ: https://github.com/hadatuna/yf-isv-sample

取得
git clone https://github.com/hadatuna/yf-isv-sample.git

埋め込みで見せているのは次の5つです。

# 内容 使用 API
① Yellowfin との SSO(シングルサインオン) REST API
② ダッシュボードの埋め込み JavaScript API v3
③ レポートの埋め込み JavaScript API v3
④ ストーリーの埋め込み REST API
⑤ シグナルの埋め込み REST API

Yellowfin は Docker Hub の all-in-one イメージ(アプリ + リポジトリDB 同梱)を使い、バージョン 9.17(REST API のメディアタイプ api-v4.2)で実機検証しています。:latest は各環境の古いキャッシュが使われ得るため、docker-compose.yml ではタグを 9.17 に固定しています。

この記事のコードは yellowfinbi/yellowfin-all-in-one:9.17(REST API のメディアタイプ api-v4.2)で動作確認したものです。別バージョンでは API のメディアタイプ等が変わる可能性があります。後述の「ハマりどころ」もあわせてご確認ください。


動作イメージ

ログイン後、タブを切り替えて5つの埋め込みを確認できます。

dash.png

ダッシュボードもレポートも、素のインストールに入っている「Ski Team」チュートリアルのコンテンツがそのまま使えます。


全体構成

2つのコンテナで構成しています。

[ブラウザ] ──┬─→ [app コンテナ(Node/Express)]  : ログイン画面 + REST プロキシ + SSO
            │         │
            │         └─(サーバ間, REST API)→ [yellowfin コンテナ(本体+DB)]
            └─(JS API v3 / iframe で直接)──────→ [yellowfin コンテナ]
  • app コンテナ(=外部サイト相当)が、自前のログイン画面で独自ユーザーを認証します。
  • ログイン後、そのユーザーを Yellowfin ユーザーにマッピングし、サーバ側で REST API を叩いて SSO します。
  • ブラウザは Yellowfin から JavaScript API v3 を読み込み、ダッシュボード/レポートを <div> に埋め込みます。
  • ストーリー/シグナルは app コンテナが REST API で取得し、その結果を表示します。

SSO のしくみ(重要)

[ユーザー] --(自前ID/PW)--> [サンプルアプリ] --(管理者API資格情報 + ユーザー名)--> [Yellowfin]
                              本人確認            ログイントークン発行(noPassword)

エンドユーザーは Yellowfin の資格情報を入力しません。サンプルアプリが自分で本人確認し、
ログイン中ユーザーにマッピングされた Yellowfin ユーザーとしてセッションを発行します。
ユーザーのパスワードも Yellowfin 管理者の資格情報も、ブラウザや Yellowfin には渡しません。
これが「再ログイン不要(シングルサインオン)」の埋め込みです。

ディレクトリ構成

yf-isv-sample/
├─ start.sh / start.bat    # ワンショット起動(macOS-Linux / Windows)
├─ docker-compose.yml      # yellowfin(本体) + app(サンプル) の2コンテナ
├─ .env.example
├─ license/                # ライセンスファイル置き場(yellowfin.lic)
├─ scripts/
│  ├─ load-license.sh      # ライセンスを REST API で投入
│  ├─ enable-sso.sh        # SIMPLE_AUTHENTICATION を投入し Yellowfin 再起動
│  └─ enable-sso.sql
└─ app/                    # 埋め込み側サンプル(Node.js + Express)
   ├─ server.js            #   静的配信 + REST プロキシ + SSO + ログイン
   ├─ lib/yellowfin.js     #   Yellowfin REST API クライアント
   ├─ config/users.js      #   デモ用ユーザー
   ├─ scripts/load-license.js
   └─ public/              #   タブ UI(HTML/JS/CSS)

必要なもの

  • Docker Desktop(Docker Engine + Docker Compose 同梱)
  • Yellowfin のライセンスファイル(別途入手してください)
  • メモリ目安 6GB 以上(初期設定ではYellowfin に 4GB 割り当てています。適宜変更可能)

使い方

license/ にライセンスファイル yellowfin.lic (←ファイル名を変更してください)を置いて、起動スクリプトを実行するだけです。

macOS / Linux:

$ ./start.sh

Windows:

start.bat

このスクリプト1つで、次をすべて自動でやります。

  1. docker compose up -d(コンテナ起動)
  2. Yellowfin の起動完了を待機
  3. ライセンスを REST API で投入
  4. パスワードなし SSO を許可(SIMPLE_AUTHENTICATION を投入し Yellowfin を再起動)
  5. 再起動後の起動完了を待機

✓ 準備完了! と出たら、ブラウザで http://localhost:3000 を開きます。
デモ用アカウントは alice / alice123 または bob / bob123 です(これはサンプルアプリ側のユーザーで、Yellowfin のものではありません)。

all-in-one イメージはデータを永続化しません。docker compose down で作り直したら、もう一度 start.sh / start.bat を実行してください(ライセンス投入と SSO 許可を再適用します)。restart では消えません。

初回はイメージ(約3.8GB)の取得+Yellowfin の初回ブートで時間がかかります。start.sh / start.bat は既定で最大30分待ちます(WAIT_MINUTES=45 ./start.sh で変更可)。タイムアウトする場合は Docker Desktop のメモリを 6GB 以上にするのが効きます(Yellowfin は 4GB 必要)。

ポート番号を変えたい場合(.env の作り方)

既定では Yellowfin が 8080、サンプルアプリが 3000 を使います。別ポートにしたいときは
.env.example をコピーして .env を作り、ポートを書き換えます。

.env を作る
cp .env.example .env

.env を編集します(例: Yellowfin を 9090、アプリを 3001 にする場合):

.env(抜粋)
YF_HOST_PORT=9090
APP_HOST_PORT=3001
# ↓ ブラウザから見た Yellowfin の URL。YF_HOST_PORT を変えたら必ずここも合わせる
YF_PUBLIC_URL=http://localhost:9090
  • 重要: YF_HOST_PORT を変えたら YF_PUBLIC_URL も同じポートにしてください。
    これはブラウザが JS API を読み込む URL なので、ズレると埋め込みが動きません。
  • start.sh / start.bat は .env のポートを自動で読み取って待機・案内します。
  • 変更後は docker compose down してから ./start.sh(start.bat)を再実行してください。

コードと解説

ここからは各ファイルの全文と簡単な解説です。コア(SSO の肝)は app/lib/yellowfin.js と app/public/app.js です。

docker-compose.yml

yellowfin(本体)と app(サンプル)の2サービスです。
ポイントは、バックエンドから見た URL(http://yellowfin:8080)とブラウザから見た URL(http://localhost:8080)を分けて渡しているところです。JS API のスクリプトはブラウザが読み込むので、後者が必要になります。
プラウザ側のポート番号やアプリに割り当てるメモリは適宜変更可能です。

docker-compose.yml
services:
  # ----------------------------------------------------------------------------
  # Yellowfin 本体(All-In-One: アプリ + リポジトリDB を1コンテナに同梱)
  #   - ライセンスは起動後に Web UI から投入する(環境変数では渡せない)
  #   - このイメージはデータを永続化しない。停止すると内容は失われる(デモ用途)
  #   - バージョンは 9.17 に固定(api-v4.2 で検証済み)。:latest は各環境の古い
  #     キャッシュが使われ得るため固定タグにしている。.env の YF_IMAGE_TAG で上書き可。
  # ----------------------------------------------------------------------------
  yellowfin:
    image: yellowfinbi/yellowfin-all-in-one:${YF_IMAGE_TAG:-9.17}
    container_name: yf-isv-yellowfin
    ports:
      - "${YF_HOST_PORT:-8080}:8080"
    environment:
      # Yellowfin アプリに割り当てるメモリ(MB)。未指定だとRAMの約25%。
      APP_MEMORY: "${YF_APP_MEMORY:-4096}"
    volumes:
      # ライセンス投入を補助するために license/ をコンテナ内へマウント(任意)。
      # 実際の適用は Web UI(管理 > ライセンス管理)から行う。
      - ./license:/license:ro
    healthcheck:
      # ログイン画面が応答するまでを healthy とみなす(初回は数分かかる)
      test: ["CMD-SHELL", "curl -sf http://localhost:8080/logon.i4 >/dev/null || exit 1"]
      interval: 15s
      timeout: 10s
      retries: 40
      start_period: 180s

  # ----------------------------------------------------------------------------
  # 埋め込みサンプルアプリ(外部サイト相当)。Node.js + Express。
  #   - サーバ側で REST API による SSO を行い、ブラウザに管理者資格情報を渡さない
  #   - ブラウザは JS API v3 でダッシュボード/レポートを埋め込む
  # ----------------------------------------------------------------------------
  app:
    build: ./app
    container_name: yf-isv-app
    ports:
      - "${APP_HOST_PORT:-3000}:3000"
    volumes:
      # ライセンス投入(REST)時にコンテナ内 Node が読み取るためマウント(読み取り専用)
      - ./license:/license:ro
    environment:
      PORT: "3000"
      # バックエンド(コンテナ間)から見た Yellowfin の URL
      YF_INTERNAL_URL: "${YF_INTERNAL_URL:-http://yellowfin:8080}"
      # ブラウザ(ホスト)から見た Yellowfin の URL(JS API スクリプトの読込元)
      YF_PUBLIC_URL: "${YF_PUBLIC_URL:-http://localhost:8080}"
      # SSO に使う Yellowfin 管理者の資格情報(サーバ側のみで使用)
      YF_ADMIN_USER: "${YF_ADMIN_USER:-admin@yellowfin.com.au}"
      YF_ADMIN_PASSWORD: "${YF_ADMIN_PASSWORD:-test}"
      # デモユーザー(alice/bob)がマッピングされる Yellowfin ユーザー。既定は管理者を流用。
      YF_EMBED_USER: "${YF_EMBED_USER:-admin@yellowfin.com.au}"
      # ログインセッションの署名鍵(本番では必ず変更する)
      SESSION_SECRET: "${SESSION_SECRET:-yf-isv-sample-dev-secret}"
    depends_on:
      - yellowfin
    restart: unless-stopped

.env.example

設定はすべて環境変数で渡せるようにしています。通常は変更不要です。

.env.example
# ---- ホスト側の公開ポート -------------------------------------------------
YF_HOST_PORT=8080          # Yellowfin を開くポート -> http://localhost:8080
APP_HOST_PORT=3000         # サンプルアプリを開くポート -> http://localhost:3000

# ---- Yellowfin コンテナ ----------------------------------------------------
YF_IMAGE_TAG=9.17          # 使用する Yellowfin イメージのタグ(api-v4.2 で検証済み)
YF_APP_MEMORY=4096         # Yellowfin に割り当てるメモリ(MB)

# ---- サンプルアプリ -> Yellowfin の URL -----------------------------------
# コンテナ間通信用(compose のサービス名で解決)
YF_INTERNAL_URL=http://yellowfin:8080
# ブラウザ(ホスト)から見える URL。JS API スクリプトの読込元になる。
# 別ホストや別ポートに公開する場合はここを合わせて変更する。
YF_PUBLIC_URL=http://localhost:8080

# ---- Yellowfin 認証情報(サーバ側のみで使用)------------------------------
YF_ADMIN_USER=admin@yellowfin.com.au
YF_ADMIN_PASSWORD=test
# デモユーザー(alice/bob)がマッピングされる Yellowfin ユーザー。デモでは管理者を流用。
# 本番では Yellowfin 側に閲覧用ユーザーを作り、app/config/users.js でユーザーごとに割り当てる。
YF_EMBED_USER=admin@yellowfin.com.au

# ---- サンプルアプリのログインセッション ------------------------------------
SESSION_SECRET=yf-isv-sample-dev-secret

start.sh(ワンショット起動)

起動からライセンス投入、SSO 許可、再起動待ちまでを順番に流します。
SSO 許可の反映には Yellowfin の再起動が必要(後述)なので、「起動→待機→投入→再起動→再待機」という順序制御をホスト側でやっています。

start.sh
#!/usr/bin/env bash
#
# ワンショット起動スクリプト。
#   1) docker compose up -d   (初回はイメージ pull に時間がかかる)
#   2) Yellowfin の起動完了を待つ
#   3) ライセンスを REST API で投入
#   4) パスワードなし SSO を許可(DB 投入 + Yellowfin 再起動)
#   5) 再起動後の起動完了を待つ
#
# 各ステップは冪等なので、再実行しても問題ない。
#
# 使い方:
#   ./start.sh [ライセンスファイル]      # 省略時は license/yellowfin.lic
#   待ち時間を変える:  WAIT_MINUTES=45 ./start.sh
#
set -euo pipefail
cd "$(dirname "$0")"

# .env があればポート等の設定を読む(無ければ既定値)
[ -f .env ] && set -a && . ./.env && set +a
YF_PORT="${YF_HOST_PORT:-8080}"
APP_PORT="${APP_HOST_PORT:-3000}"
LIC_FILE="${1:-license/yellowfin.lic}"
WAIT_MINUTES="${WAIT_MINUTES:-30}"
WAIT_RETRIES=$(( WAIT_MINUTES * 60 / 5 ))

if [ ! -f "$LIC_FILE" ]; then
  echo "✗ ライセンスが見つかりません: $LIC_FILE" >&2
  echo "  license/ にライセンスファイルを置いてから再実行してください。" >&2
  exit 1
fi

# ポート使用チェック(使用中でも本サンプル自身の可能性があるので警告のみ)
check_port() {
  if command -v lsof >/dev/null 2>&1 && lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; then
    echo "⚠ ポート $1 は既に使用中です。別アプリが使っている場合は .env で $2 を変更してください。" >&2
    echo "  (本サンプルを既に起動している場合はこの警告は無視して構いません)" >&2
  fi
}
check_port "$YF_PORT" YF_HOST_PORT
check_port "$APP_PORT" APP_HOST_PORT

# 失敗時の診断情報を表示
diagnose() {
  echo "" >&2
  echo "---- 診断情報 ----" >&2
  docker compose ps >&2 2>/dev/null || true
  echo "---- yellowfin ログ(末尾60行) ----" >&2
  docker compose logs --tail=60 yellowfin >&2 2>/dev/null || true
  echo "" >&2
  echo "よくある原因:" >&2
  echo "  - Docker のメモリ不足(Yellowfin は 4GB 必要。Docker Desktop の Memory を 6GB 以上に)" >&2
  echo "  - 初回ブートが遅い(遅いマシン/ディスク, Apple Silicon の amd64 エミュレーション)" >&2
  echo "  - ポート競合(${YF_PORT} / ${APP_PORT} が他のアプリで使用中)" >&2
}

# Yellowfin のログイン画面が応答するまで待つ
wait_yellowfin() {
  local label="$1"
  echo "==> Yellowfin の起動を待っています(${label} / 最大 ${WAIT_MINUTES} 分)..."
  for i in $(seq 1 "$WAIT_RETRIES"); do
    if curl -sf -o /dev/null "http://localhost:${YF_PORT}/logon.i4" 2>/dev/null; then
      echo "    起動完了"
      return 0
    fi
    sleep 5
  done
  echo "✗ Yellowfin が起動しませんでした(${WAIT_MINUTES}分でタイムアウト)" >&2
  diagnose
  exit 1
}

echo "==> [1/5] コンテナを起動します"
echo "    ※ 初回はイメージ(約3.8GB)の取得に数分〜十数分かかることがあります。"
docker compose up -d || { echo "✗ コンテナの起動に失敗しました" >&2; diagnose; exit 1; }

echo "==> [2/5] 起動待ち"
wait_yellowfin "初回起動"

echo "==> [3/5] ライセンスを投入します"
# 起動直後はリポジトリDB初期化中のことがあるので数回リトライ
for attempt in 1 2 3 4 5; do
  if ./scripts/load-license.sh "$LIC_FILE"; then
    break
  fi
  echo "    ライセンス投入を再試行します(${attempt}/5)..."
  sleep 10
  [ "$attempt" = 5 ] && { echo "✗ ライセンス投入に失敗しました" >&2; diagnose; exit 1; }
done

echo "==> [4/5] パスワードなし SSO を許可します(Yellowfin を再起動します)"
./scripts/enable-sso.sh

echo "==> [5/5] 再起動後の起動待ち"
wait_yellowfin "SSO 設定の反映後"

echo ""
echo "✓ 準備完了!"
echo "  サンプルアプリ : http://localhost:${APP_PORT}   (alice/alice123 または bob/bob123)"
echo "  Yellowfin 本体 : http://localhost:${YF_PORT}     (admin@yellowfin.com.au / test)"

start.bat(Windows 用)

中身は start.sh と同じ処理です。docker compose exec で呼ぶコマンドは Unix 版とまったく同じにして、差分をバッチ構文(待機ループ・標準入力リダイレクト)だけに閉じ込めています。

start.bat
@echo off
rem ============================================================================
rem  Windows 用ワンショット起動スクリプト(start.sh の Windows 版)
rem    1) docker compose up -d   (初回はイメージ pull に時間がかかる)
rem    2) Yellowfin の起動完了を待つ
rem    3) ライセンスを REST API で投入
rem    4) パスワードなし SSO を許可(DB 投入 + Yellowfin 再起動)
rem    5) 再起動後の起動完了を待つ
rem
rem  使い方:  ダブルクリック、または cmd で  start.bat
rem  ※ Docker Desktop と、Windows 10/11 同梱の curl が必要です。
rem  ※ ポートは .env(YF_HOST_PORT / APP_HOST_PORT)から自動で読み込みます。
rem ============================================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"

set "YF_PORT=8080"
set "APP_PORT=3000"
set "LIC_NAME=yellowfin.lic"
set "WAIT_MINUTES=30"

rem .env があればポート設定を読む(行頭#は無視、インラインコメントは除去)
if exist ".env" (
  for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do (
    if /i "%%A"=="YF_HOST_PORT"  for /f "tokens=1" %%V in ("%%B") do set "YF_PORT=%%V"
    if /i "%%A"=="APP_HOST_PORT" for /f "tokens=1" %%V in ("%%B") do set "APP_PORT=%%V"
  )
)
set /a WAIT_RETRIES=%WAIT_MINUTES%*60/5

if not exist "license\%LIC_NAME%" (
  echo [ERROR] ライセンスが見つかりません: license\%LIC_NAME%
  echo         license\ フォルダにライセンスファイルを置いてから再実行してください。
  goto :fail
)

rem ポート使用チェック(警告のみ。本サンプル自身が使用中の場合もあるため)
netstat -ano | findstr "LISTENING" | findstr ":%YF_PORT% " >NUL && echo [WARN] ポート %YF_PORT% は使用中です。別アプリが使っている場合は .env の YF_HOST_PORT を変更してください。
netstat -ano | findstr "LISTENING" | findstr ":%APP_PORT% " >NUL && echo [WARN] ポート %APP_PORT% は使用中です。別アプリが使っている場合は .env の APP_HOST_PORT を変更してください。

echo [1/5] コンテナを起動します(初回はイメージ取得に数分〜十数分かかることがあります)
docker compose up -d || goto :fail

echo [2/5] 起動待ち
call :wait_yellowfin || goto :fail

echo [3/5] ライセンスを投入します
set /a tries=0
:lic_retry
docker compose exec -T -e LICENSE_PATH=/license/%LIC_NAME% app node scripts/load-license.js && goto :lic_done
set /a tries+=1
if !tries! geq 5 ( echo [ERROR] ライセンス投入に失敗しました & goto :fail )
echo     再試行します !tries!/5 ...
timeout /t 10 /nobreak >NUL
goto :lic_retry
:lic_done

echo [4/5] パスワードなし SSO を許可します(Yellowfin を再起動します)
docker compose exec -T -u postgres yellowfin psql -d yellowfin -v ON_ERROR_STOP=1 < scripts\enable-sso.sql || goto :fail
docker compose restart yellowfin || goto :fail

echo [5/5] 再起動後の起動待ち
call :wait_yellowfin || goto :fail

echo.
echo [OK] 準備完了!
echo   サンプルアプリ : http://localhost:%APP_PORT%   ^(alice/alice123 または bob/bob123^)
echo   Yellowfin 本体 : http://localhost:%YF_PORT%     ^(admin@yellowfin.com.au / test^)
echo.
pause
endlocal
exit /b 0

rem --- Yellowfin のログイン画面が応答するまで待つサブルーチン -----------------
:wait_yellowfin
echo     Yellowfin の起動を待っています(最大 %WAIT_MINUTES% 分)...
set /a wtries=0
:wl
curl -sf -o NUL "http://localhost:%YF_PORT%/logon.i4" >NUL 2>&1 && ( echo     起動完了 & exit /b 0 )
set /a wtries+=1
if !wtries! gtr %WAIT_RETRIES% (
  echo [ERROR] Yellowfin が起動しませんでした(%WAIT_MINUTES%分でタイムアウト)
  call :diagnose
  exit /b 1
)
timeout /t 5 /nobreak >NUL
goto :wl

rem --- 失敗時の診断情報 -------------------------------------------------------
:diagnose
echo.
echo ---- 診断情報 ----
docker compose ps
echo ---- yellowfin ログ(末尾60行) ----
docker compose logs --tail=60 yellowfin
echo.
echo よくある原因: Docker のメモリ不足(6GB以上推奨) / 初回ブートが遅い / ポート競合
exit /b 0

:fail
echo.
echo 失敗しました。上のメッセージを確認してください。
pause
endlocal
exit /b 1

scripts/load-license.sh

ライセンス投入は、license/ を app コンテナにマウントしておき、コンテナ内の Node がファイルを読んで REST API で送るだけにしています。こうすると Windows/Unix で同じコマンドになり、base64 のような OS 依存コマンドが要りません。

scripts/load-license.sh
#!/usr/bin/env bash
#
# ライセンスファイルを Yellowfin に REST API で投入する。
# license/ は app コンテナの /license にマウントされており、コンテナ内の Node が
# 直接読み込んで投入するため、ホスト OS 依存のコマンド(base64 等)は不要。
#
# 使い方:
#   ./scripts/load-license.sh [ファイル名]
#   (省略時は yellowfin.lic。license/ 内のファイル名を指定する)
#
set -euo pipefail
cd "$(dirname "$0")/.."

NAME="$(basename "${1:-yellowfin.lic}")"
echo "==> license/${NAME} を Yellowfin に REST API で投入します"

docker compose exec -T -e "LICENSE_PATH=/license/${NAME}" app node scripts/load-license.js

scripts/enable-sso.sh と enable-sso.sql

パスワードなし SSO を許可するための DB 設定を投入し、Yellowfin を再起動します。
内部 PostgreSQL へは OS ユーザー postgres・DB yellowfin(peer 認証)で接続します。

scripts/enable-sso.sh
#!/usr/bin/env bash
#
# Yellowfin で「パスワードなし SSO」を許可するための設定を投入するスクリプト。
# all-in-one イメージ内の PostgreSQL(リポジトリDB)に SIMPLE_AUTHENTICATION=TRUE を入れ、
# 設定を反映させるため Yellowfin を再起動する。
#
# ★ このイメージはデータを永続化しないため、`docker compose up` でコンテナを
#    作り直すたびに、ライセンス投入後に本スクリプトを1回実行する必要がある。
#    (restart では消えないが、down→up では消える)
#
# 使い方:
#   ./scripts/enable-sso.sh
#
# 内部 PostgreSQL は OS ユーザー postgres による peer 認証で接続する(DB: yellowfin)。
# 構成が異なる場合は下の変数を環境変数で上書きする。
#   YF_SERVICE  (default: yellowfin)   docker compose のサービス名
#   YF_DB_NAME  (default: yellowfin)   リポジトリDB名
#   YF_OS_USER  (default: postgres)    psql を実行する OS ユーザー(peer 認証用)
#
set -euo pipefail
cd "$(dirname "$0")/.."

SERVICE="${YF_SERVICE:-yellowfin}"
DB_NAME="${YF_DB_NAME:-yellowfin}"
OS_USER="${YF_OS_USER:-postgres}"

echo "==> ${SERVICE} の PostgreSQL(${DB_NAME}) に SIMPLE_AUTHENTICATION=TRUE を投入します"

# SQL は scripts/enable-sso.sql を標準入力で流し込む(Windows の .bat と同一方式)
docker compose exec -T -u "${OS_USER}" "${SERVICE}" psql -d "${DB_NAME}" -v ON_ERROR_STOP=1 < scripts/enable-sso.sql

echo "==> 投入結果:"
docker compose exec -T -u "${OS_USER}" "${SERVICE}" psql -d "${DB_NAME}" -tAc \
  "select ConfigCode||'='||ConfigData from Configuration where ConfigCode='SIMPLE_AUTHENTICATION';"

echo "==> 設定反映のため Yellowfin を再起動します(数分かかる場合があります)"
docker compose restart "${SERVICE}"

echo "==> 完了。http://localhost:3000 でログイン → SSO タブを試してください。"
scripts/enable-sso.sql
-- Yellowfin で「パスワードなし SSO(noPassword:true)」を許可するための設定。
-- Configuration テーブルに SIMPLE_AUTHENTICATION=TRUE を投入する。
-- 既存行があれば消してから入れ直す(冪等)。
DELETE FROM Configuration
 WHERE IpOrg = 1 AND ConfigTypeCode = 'SYSTEM' AND ConfigCode = 'SIMPLE_AUTHENTICATION';

INSERT INTO Configuration (IpOrg, ConfigTypeCode, ConfigCode, ConfigData)
VALUES (1, 'SYSTEM', 'SIMPLE_AUTHENTICATION', 'TRUE');

app/Dockerfile と package.json

Node 20 なら fetch(undici)が標準で使えるので、依存は実質 express と express-session だけです。

app/Dockerfile
# Node 20 で fetch(undici) が標準利用できるため追加依存は express のみ
FROM node:20-alpine

WORKDIR /usr/src/app

# 依存だけ先に入れてレイヤキャッシュを効かせる
COPY package.json ./
RUN npm install --omit=dev

# アプリ本体
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]
app/package.json
{
  "name": "yf-isv-sample-app",
  "version": "1.0.0",
  "description": "Yellowfin 埋め込みサンプル(外部サイト相当の Express アプリ)",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "express-session": "^1.18.0"
  },
  "engines": {
    "node": ">=20"
  }
}

app/lib/yellowfin.js(★コア:REST API クライアント)

ここが SSO の心臓部です。管理者の資格情報やアクセストークンはこのモジュール(=サーバ側)だけで扱い、ブラウザには一切出しません。
やっていることは大きく4つです。

  1. getAccessToken() … 管理者でログインし、refresh / access トークンを取得(キャッシュ付き)
  2. uploadLicence() … ライセンスを REST API で投入
  3. createSsoToken() … 埋め込みユーザーの SSO ログイントークンを発行(/api/login-tokens)
  4. listDashboards() ほか … 埋め込むコンテンツの一覧を取得

特に注意したいのが、認証ヘッダの形式とメディアタイプです。Yellowfin の REST API は
Accept / Content-Type に application/vnd.yellowfin.api-v4.2+json のようなバージョン付き MIMEを要求します(application/json だと UNKNOWN_VERSION で弾かれます)。

app/lib/yellowfin.js
'use strict';

/**
 * Yellowfin REST API クライアント
 * -----------------------------------------------------------------------------
 * このモジュールは「サーバ側だけ」で実行される。Yellowfin 管理者の資格情報や
 * アクセストークンをブラウザに渡さないことが、SSO 埋め込みのセキュリティの肝。
 *
 * 認証ヘッダの形式(Yellowfin 9.x REST API):
 *   Authorization: YELLOWFIN ts=<エポックms>, nonce=<乱数>
 * トークンが必要な呼び出しでは末尾に `, token=<accessToken>` を付与する。
 *
 * 大まかな流れ:
 *   1) POST /api/refresh-tokens  … 管理者でログインし refresh/access トークンを取得
 *   2) POST /api/login-tokens    … 埋め込みユーザーの SSO ログイントークン発行
 *
 * ★ 本ファイルの値は yellowfin-all-in-one:9.17(API メディアタイプ api-v4.2)で
 *   実機検証済み。別バージョンを使う場合はメディアタイプ等を調整すること。
 */

const YF_INTERNAL_URL = (process.env.YF_INTERNAL_URL || 'http://yellowfin:8080').replace(/\/$/, '');
const YF_ADMIN_USER = process.env.YF_ADMIN_USER || 'admin@yellowfin.com.au';
const YF_ADMIN_PASSWORD = process.env.YF_ADMIN_PASSWORD || 'test';
const YF_EMBED_USER = process.env.YF_EMBED_USER || YF_ADMIN_USER;

// access トークンの簡易メモリキャッシュ(短命なので失効したら取り直す)
let cachedAccessToken = null;
let cachedAccessTokenExpiry = 0;

// Yellowfin REST API はバージョン付きメディアタイプを要求する。
// (application/json や */* では UNKNOWN_VERSION / 500 になる)
// バージョン番号は Yellowfin のリリースに対応。環境変数で上書き可能。
const API_MEDIA_TYPE = process.env.YF_API_MEDIA_TYPE || 'application/vnd.yellowfin.api-v4.2+json';

/** Authorization ヘッダ文字列を生成する */
function authHeader(token) {
  const ts = Date.now();
  const nonce = Math.floor(Math.random() * 1e16);
  let header = `YELLOWFIN ts=${ts}, nonce=${nonce}`;
  if (token) header += `, token=${token}`;
  return header;
}

/**
 * REST API 呼び出しの薄いラッパ。
 * Yellowfin の REST API は Accept/Content-Type にバージョン付き MIME を要求する。
 */
async function yfFetch(path, { method = 'GET', token, body } = {}) {
  const url = `${YF_INTERNAL_URL}${path}`;
  const headers = {
    'Authorization': authHeader(token),
    'Accept': API_MEDIA_TYPE,
    'Content-Type': API_MEDIA_TYPE,
  };
  const res = await fetch(url, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  let data;
  try {
    data = text ? JSON.parse(text) : null;
  } catch {
    data = text; // JSON でなければ生テキスト(ストーリー HTML 等)
  }

  if (!res.ok) {
    const msg = (data && (data.detail || data.title || data.message)) || `HTTP ${res.status}`;
    const err = new Error(`Yellowfin API error: ${method} ${path} -> ${msg}`);
    err.status = res.status;
    err.body = data;
    throw err;
  }
  return data;
}

/**
 * 管理者でログインし access トークンを取得(キャッシュ付き)。
 * refresh-tokens のレスポンスには refresh トークンと、_embedded に access トークンが含まれる。
 */
async function getAccessToken({ force = false } = {}) {
  const now = Date.now();
  if (!force && cachedAccessToken && now < cachedAccessTokenExpiry) {
    return cachedAccessToken;
  }

  const data = await yfFetch('/api/refresh-tokens', {
    method: 'POST',
    body: { userName: YF_ADMIN_USER, password: YF_ADMIN_PASSWORD },
  });

  // レスポンス構造(実機確認済み):
  //   data.securityToken (refresh) と data._embedded.accessToken.securityToken (access, JWT)
  const access =
    data?._embedded?.accessToken?.securityToken ||
    data?._embedded?.accessToken?.token ||
    data?.accessToken ||
    data?.securityToken;

  if (!access) {
    throw new Error('access トークンを取得できませんでした(レスポンス構造を api-docs で確認してください)');
  }

  cachedAccessToken = access;
  // access トークンの有効期間。Yellowfin は秒数(例: 1200)で返す。
  const expirySec = Number(data?._embedded?.accessToken?.expiry || data?.expiry);
  const ttlMs = Number.isFinite(expirySec) && expirySec > 0 ? expirySec * 1000 : 4 * 60_000;
  cachedAccessTokenExpiry = now + ttlMs - 30_000; // 30秒の安全マージン
  return access;
}

/**
 * ライセンスファイルを REST API で投入する(新規・更新とも同じ)。
 * POST /api/rpc/licence-management/upload-licence (マルチパート, フォーム項目 newLicence)
 * ライセンス未投入の状態でも admin の access トークンは取得でき、本APIで投入できる。
 */
async function uploadLicence(buffer, filename = 'yellowfin.lic') {
  return withAccessToken(async (token) => {
    const form = new FormData();
    form.append('newLicence', new Blob([buffer]), filename);
    const res = await fetch(`${YF_INTERNAL_URL}/api/rpc/licence-management/upload-licence`, {
      method: 'POST',
      // Content-Type は FormData が boundary 付きで自動設定するので指定しない
      headers: { 'Authorization': authHeader(token), 'Accept': API_MEDIA_TYPE },
      body: form,
    });
    const text = await res.text();
    let data; try { data = text ? JSON.parse(text) : text; } catch { data = text; }
    if (!res.ok) {
      const err = new Error(`upload-licence failed: HTTP ${res.status}`);
      err.status = res.status; err.body = data; throw err;
    }
    return data;
  });
}

/** access トークンが必要な呼び出しを、失効時に1回だけ再試行するヘルパ */
async function withAccessToken(fn) {
  let token = await getAccessToken();
  try {
    return await fn(token);
  } catch (err) {
    if (err.status === 401 || err.status === 403) {
      token = await getAccessToken({ force: true });
      return await fn(token);
    }
    throw err;
  }
}

// noPassword SSO 用のダミーパスワード。
// noPassword:true のとき Yellowfin はこの値を検証しないため、実パスワードは不要。
// (ただし下記の前提設定が有効であること)
const SSO_DUMMY_PASSWORD = process.env.SSO_DUMMY_PASSWORD || 'sso-no-password';

/**
 * SSO ログイントークン(securityToken)を発行する。
 * 返ってきた securityToken を JS API v3 の init / ロードに渡して埋め込みに使う。
 *
 * エンドポイント: POST /api/login-tokens
 * ボディ: { signOnUser: { userName, password(ダミー) }, noPassword: true }
 *
 * ★前提(重要): Yellowfin 側で SIMPLE_AUTHENTICATION=TRUE が有効であること。
 *   無効だと UNSECURE_LOGON_NOT_ENABLED となり失敗する。
 *   有効化は scripts/enable-sso.sh(Configuration テーブルへ INSERT し Yellowfin 再起動)。
 *   参考: https://qiita.com/celery/items/69f8da9601277807cc34
 *
 * @param {string} userName        マッピング先の Yellowfin ユーザー名
 * @param {string[]} loginParameters 例: ["ENTRY=DASHBOARD"](任意)
 */
async function createSsoToken(userName = YF_EMBED_USER, loginParameters = []) {
  return withAccessToken((token) =>
    yfFetch('/api/login-tokens', {
      method: 'POST',
      token,
      body: {
        signOnUser: { userName, password: SSO_DUMMY_PASSWORD },
        noPassword: true,
        loginParameters,
      },
    })
  );
}

/**
 * 発行済みの SSO ログイントークン(とそのセッション)を失効させる。
 * DELETE /api/login-tokens/{tokenId}(ログアウト時に呼ぶ)。
 */
async function deleteLoginToken(tokenId) {
  return withAccessToken((token) =>
    yfFetch(`/api/login-tokens/${encodeURIComponent(tokenId)}`, { method: 'DELETE', token })
  );
}

/**
 * 一覧レスポンスから配列を取り出す。
 * この Yellowfin の REST API は { items: [...] } 形式で返す(_embedded/直配列も一応吸収)。
 */
function pickList(data) {
  if (Array.isArray(data)) return data;
  if (Array.isArray(data?.items)) return data.items;
  if (data?._embedded) {
    const firstArr = Object.values(data._embedded).find(Array.isArray);
    if (firstArr) return firstArr;
  }
  return [];
}

/** HTML タグを除去して素のテキストにする(シグナルのナラティブ用) */
function stripHtml(s) {
  return String(s || '').replace(/<[^>]*>/g, '').trim();
}

/** ダッシュボード一覧。JS API には dashboardPublishUUID を渡す。 */
async function listDashboards() {
  const data = await withAccessToken((token) =>
    yfFetch('/api/dashboards?size=50', { token })
  );
  return pickList(data).map((d) => ({
    id: d.dashboardPublishUUID || d.dashboardUUID,
    name: d.name || '(no name)',
    category: [d.categoryName, d.subCategoryName].filter(Boolean).join(' / '),
  }));
}

/** レポート一覧。JS API には reportPublishUUID を渡す。 */
async function listReports() {
  const data = await withAccessToken((token) =>
    yfFetch('/api/reports?size=50', { token })
  );
  return pickList(data).map((r) => ({
    id: r.reportPublishUUID || r.reportUUID,
    name: r.name || '(no name)',
    category: [r.categoryName, r.subCategoryName].filter(Boolean).join(' / '),
  }));
}

/** ストーリー一覧。本文取得には storyUuid を使う。 */
async function listStories() {
  const data = await withAccessToken((token) =>
    yfFetch('/api/stories?size=50', { token })
  );
  return pickList(data).map((s) => ({
    id: s.storyUuid,
    name: s.title || '(no title)',
    description: s.description || '',
  }));
}

/** ストーリーのメタデータ(タイトル・説明・著者等)を REST で取得。 */
async function getStory(id) {
  return withAccessToken((token) =>
    yfFetch(`/api/stories/${encodeURIComponent(id)}`, { token })
  );
}

/**
 * ストーリー本文を表示するための署名付き URL を REST で取得する。
 * /api/stories/{uuid}/body は署名付き /StoryBody.i4?...&s=... へ 302 する。
 * その Location(相対パス)を返す。ブラウザを直接そこへ飛ばせば、Yellowfin が
 * 本文 HTML をレンダリングして返す(署名トークンで認証されるため別ログイン不要)。
 */
async function getStoryBodyUrl(id) {
  return withAccessToken(async (token) => {
    const res = await fetch(`${YF_INTERNAL_URL}/api/stories/${encodeURIComponent(id)}/body`, {
      headers: { 'Authorization': authHeader(token), 'Accept': API_MEDIA_TYPE },
      redirect: 'manual',
    });
    const loc = res.headers.get('location');
    if (!loc) {
      const err = new Error(`story body: リダイレクト先が取得できません (HTTP ${res.status})`);
      err.status = res.status; throw err;
    }
    return loc; // 例: /StoryBody.i4?storyUUID=...&s=...
  });
}

/** シグナル一覧/内容を REST で取得。limit パラメータが必須。 */
async function listSignals() {
  const data = await withAccessToken((token) =>
    yfFetch('/api/signals?limit=50', { token })
  );
  return pickList(data).map((s) => ({
    id: s.signalUuid,
    title: stripHtml(s.narrativeText?.unformattedText) || '(signal)',
    value: stripHtml(s.narrativeValue?.unformattedText) || '',
    // ナラティブは Yellowfin 生成の安全な HTML。フロントでそのまま描画する。
    html: s.narrativeText?.unformattedText || '',
    date: stripHtml(s.narrativeDate?.unformattedText) || null,
  }));
}

module.exports = {
  YF_INTERNAL_URL,
  YF_EMBED_USER,
  authHeader,
  withAccessToken,
  getAccessToken,
  uploadLicence,
  createSsoToken,
  deleteLoginToken,
  listDashboards,
  listReports,
  listStories,
  getStory,
  getStoryBodyUrl,
  listSignals,
};

app/config/users.js(デモ用ユーザー)

「外部サイト側」が独自に持っているユーザーの例です。デモなのでハードコードしています。
password はこのサンプルアプリにログインするためのもので、Yellowfin には渡しません。
yfUser が、ログイン後にマッピングする Yellowfin 側のユーザー名です。
素のインストールには admin しか居ないので、既定では全員 admin にマッピングして「そのまま動く」ようにしています。

app/config/users.js
'use strict';

/**
 * ホストアプリ(このサンプル)自身のユーザーストア。
 * -----------------------------------------------------------------------------
 * これは「外部サイト側」が独自に管理するユーザーの例(デモ用ハードコード)。
 *
 *  - password : このサンプルアプリにログインするためのパスワード。
 *               ★ Yellowfin には渡さない。アプリ側で本人確認するだけ。
 *  - yfUser   : ログイン成功後に SSO でマッピングする Yellowfin 側のユーザー名。
 *               サーバが管理者 API 資格情報を使い、この yfUser として
 *               Yellowfin のログイントークンを発行する(パスワード不要=SSO)。
 *
 * 既定では全員を admin@yellowfin.com.au にマッピングしている。
 * インストール直後の Yellowfin には admin しか居ないため、これで「そのまま動く」。
 * 実運用では、Yellowfin 側に閲覧用ユーザーを作成し、ここを個別の yfUser に変える。
 */

const DEFAULT_YF_USER = process.env.YF_EMBED_USER || 'admin@yellowfin.com.au';

const users = [
  {
    username: 'alice',
    password: 'alice123',
    displayName: 'Alice(営業部)',
    yfUser: DEFAULT_YF_USER,
  },
  {
    username: 'bob',
    password: 'bob123',
    displayName: 'Bob(経営企画)',
    yfUser: DEFAULT_YF_USER,
  },
];

/** username/password を検証し、一致すればユーザー情報を返す(無ければ null) */
function authenticate(username, password) {
  const u = users.find((x) => x.username === username && x.password === password);
  if (!u) return null;
  // パスワードは外に出さない
  return { username: u.username, displayName: u.displayName, yfUser: u.yfUser };
}

module.exports = { users, authenticate };

app/server.js(Express サーバ)

静的配信、自前ログイン(セッション)、そして Yellowfin REST API のプロキシです。
/api/sso/token がログイン中ユーザーの SSO トークンを発行し、/api/dashboards などはコンテンツ一覧を返します。
ストーリー本文は /api/stories/:id/body で署名付き URL を取得し、ブラウザ(iframe)を Yellowfin 側へ 302 リダイレクトします。

app/server.js
'use strict';

/**
 * 埋め込みサンプルアプリ(外部サイト相当)の Express サーバ。
 *  - public/ の静的ファイル配信
 *  - 自前のログイン(セッション)。ホストアプリ独自のユーザーで本人確認する
 *  - ログイン中ユーザーを Yellowfin ユーザーにマッピングして SSO トークンを発行
 *  - Yellowfin REST API のプロキシ(管理者資格情報はサーバ側に隠す)
 */

const path = require('path');
const express = require('express');
const session = require('express-session');
const yf = require('./lib/yellowfin');
const { authenticate } = require('./config/users');

const app = express();
const PORT = process.env.PORT || 3000;
const YF_PUBLIC_URL = (process.env.YF_PUBLIC_URL || 'http://localhost:8080').replace(/\/$/, '');

app.use(express.json());
app.use(session({
  secret: process.env.SESSION_SECRET || 'yf-isv-sample-dev-secret',
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax' },
}));
app.use(express.static(path.join(__dirname, 'public')));

// API ハンドラの共通エラーラッパ
const wrap = (fn) => (req, res) => {
  Promise.resolve(fn(req, res)).catch((err) => {
    console.error(`[API ERROR] ${req.method} ${req.path}:`, err.message);
    res.status(err.status || 500).json({
      error: err.message,
      detail: err.body || null,
    });
  });
};

// ログイン必須にするミドルウェア
function requireAuth(req, res, next) {
  if (req.session && req.session.user) return next();
  res.status(401).json({ error: 'ログインが必要です' });
}

// --- 公開エンドポイント -----------------------------------------------------

// フロントへ渡す公開設定(ブラウザから見た Yellowfin URL)
app.get('/api/config', (req, res) => {
  res.json({ yfPublicUrl: YF_PUBLIC_URL });
});

// ログイン(ホストアプリ自身の認証。Yellowfin にはパスワードを渡さない)
app.post('/api/login', (req, res) => {
  const { username, password } = req.body || {};
  const user = authenticate(username, password);
  if (!user) {
    return res.status(401).json({ error: 'ユーザー名またはパスワードが違います' });
  }
  req.session.user = user; // { username, displayName, yfUser }
  res.json({ username: user.username, displayName: user.displayName, yfUser: user.yfUser });
});

// ログアウト:Yellowfin 側の SSO ログイントークン(とセッション)を失効させてから
// アプリ自身のセッションを破棄する。
app.post('/api/logout', wrap(async (req, res) => {
  const tokenId = req.session && req.session.lastLoginTokenId;
  if (tokenId) {
    try { await yf.deleteLoginToken(tokenId); }
    catch (e) { console.warn('[logout] login-token DELETE 失敗:', e.message); }
  }
  req.session.destroy(() => res.json({ ok: true }));
}));

// 現在のログイン状態
app.get('/api/me', (req, res) => {
  if (req.session && req.session.user) {
    const u = req.session.user;
    return res.json({ username: u.username, displayName: u.displayName, yfUser: u.yfUser });
  }
  res.status(401).json({ error: '未ログイン' });
});

// --- ここから下はログイン必須 ----------------------------------------------

// SSO: ログイン中ユーザーにマッピングされた Yellowfin ユーザーで SSO トークン発行。
// 返った securityToken を JS API v3 のロードに渡して埋め込みに使う。
app.post('/api/sso/token', requireAuth, wrap(async (req, res) => {
  const yfUser = req.session.user.yfUser;
  const token = await yf.createSsoToken(yfUser);
  // ログアウト時に失効させるため tokenId を控えておく
  if (token && token.tokenId) req.session.lastLoginTokenId = token.tokenId;
  res.json({ tokenType: 'sso', yfUser, token });
}));

// コンテンツ一覧(フロントのドロップダウンを動的生成するため)
app.get('/api/dashboards', requireAuth, wrap(async (req, res) => res.json(await yf.listDashboards())));
app.get('/api/reports', requireAuth, wrap(async (req, res) => res.json(await yf.listReports())));
app.get('/api/stories', requireAuth, wrap(async (req, res) => res.json(await yf.listStories())));

// ストーリーのメタデータを REST で取得
app.get('/api/stories/:id', requireAuth, wrap(async (req, res) => res.json(await yf.getStory(req.params.id))));

// ストーリー本文を表示する。REST API で署名付き本文URLを取得し、ブラウザ(iframe)を
// その Yellowfin URL へリダイレクトする。iframe は Yellowfin 直読みになり完全描画される。
app.get('/api/stories/:id/body', requireAuth, wrap(async (req, res) => {
  const loc = await yf.getStoryBodyUrl(req.params.id);
  res.redirect(YF_PUBLIC_URL + loc);
}));

// シグナル一覧/内容を REST で取得して返す(= REST API による埋め込み)
app.get('/api/signals', requireAuth, wrap(async (req, res) => res.json(await yf.listSignals())));

app.listen(PORT, () => {
  console.log(`yf-isv-sample app listening on http://localhost:${PORT}`);
  console.log(`  Yellowfin (browser): ${YF_PUBLIC_URL}`);
  console.log(`  Yellowfin (server) : ${yf.YF_INTERNAL_URL}`);
});

app/scripts/load-license.js

ライセンス投入をコンテナ内で実行する小さなスクリプトです(前述の load-license.sh から呼ばれます)。

app/scripts/load-license.js
'use strict';

/**
 * ライセンスを REST API で投入する(コンテナ内で実行)。
 * マウントされた /license のファイルを読み、yf.uploadLicence で送る。
 * ホスト OS に依存しない(base64 等の外部コマンド不要)。
 *
 * 実行例:
 *   docker compose exec -T -e LICENSE_PATH=/license/yellowfin.lic app node scripts/load-license.js
 */

const fs = require('fs');
const path = require('path');
const yf = require('../lib/yellowfin');

const file = process.env.LICENSE_PATH || '/license/yellowfin.lic';

if (!fs.existsSync(file)) {
  console.error('ライセンスが見つかりません(コンテナ内パス):', file);
  console.error('ホストの license/ にファイルを置いているか確認してください。');
  process.exit(1);
}

const buf = fs.readFileSync(file);
yf.uploadLicence(buf, path.basename(file))
  .then(() => console.log('==> ライセンス投入 OK'))
  .catch((e) => { console.error('投入失敗:', e.message); process.exit(1); });

app/public/index.html

ログイン画面と、5つのタブ(SSO / ダッシュボード / レポート / ストーリー / シグナル)です。
JS API のスクリプトは固定で書かず、app.js が公開設定を取得してから動的に読み込みます。

app/public/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Yellowfin 埋め込みサンプル (ISV Sample)</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header>
    <h1>Yellowfin 埋め込みサンプル</h1>
    <p class="sub">外部サイトに Yellowfin を組み込むデモ — REST API による SSO / JavaScript API v3 / REST 埋め込み</p>
    <div class="header-right">
      <div id="sso-status" class="badge badge-wait">SSO: 未接続</div>
      <div id="user-area" class="user-area" hidden>
        <span id="user-name"></span>
        <button id="btn-logout" class="link-btn">ログアウト</button>
      </div>
    </div>
  </header>

  <!-- ログイン画面(ホストアプリ自身の認証)。未ログイン時のみ表示 -->
  <section id="login-screen" class="login-screen" hidden>
    <form id="login-form" class="login-card">
      <h2>サンプルアプリにログイン</h2>
      <p class="login-note">これは外部サイト(ホストアプリ)自身のログインです。<br>
        成功すると、このユーザーが Yellowfin ユーザーにマッピングされ、裏側で SSO されます。</p>
      <label>ユーザー名<input id="login-username" type="text" autocomplete="username" value="alice"></label>
      <label>パスワード<input id="login-password" type="password" autocomplete="current-password" value="alice123"></label>
      <button type="submit" class="primary">ログイン</button>
      <div id="login-error" class="login-error"></div>
      <p class="login-hint">デモ用アカウント: <code>alice / alice123</code> ・ <code>bob / bob123</code></p>
    </form>
  </section>

  <!-- ログイン後に表示する本体 -->
  <div id="app-body" hidden>
  <nav class="tabs">
    <button class="tab active" data-tab="sso">① SSO (REST)</button>
    <button class="tab" data-tab="dashboard">② ダッシュボード (JS API)</button>
    <button class="tab" data-tab="report">③ レポート (JS API)</button>
    <button class="tab" data-tab="story">④ ストーリー (REST)</button>
    <button class="tab" data-tab="signal">⑤ シグナル (REST)</button>
  </nav>

  <main>
    <!-- ① SSO -->
    <section class="panel active" id="panel-sso">
      <h2>① REST API による SSO</h2>
      <p>このアプリにログイン済みのユーザーを Yellowfin ユーザーにマッピングし、サーバ側で
         REST API(管理者で認証 → ログイントークン発行)を実行します。
         <strong>あなたのパスワードも管理者資格情報も Yellowfin / ブラウザには渡していません。</strong></p>
      <button id="btn-sso" class="primary">SSO トークンを取得</button>
      <pre id="sso-out" class="out">(ここに結果が表示されます)</pre>
    </section>

    <!-- ② ダッシュボード -->
    <section class="panel" id="panel-dashboard">
      <h2>② JavaScript API v3 によるダッシュボード埋め込み</h2>
      <div class="controls">
        <select id="dashboard-select"><option value="">読み込み中...</option></select>
        <button id="btn-dashboard" class="primary">埋め込む</button>
      </div>
      <div id="dashboard-container" class="embed-container"></div>
    </section>

    <!-- ③ レポート -->
    <section class="panel" id="panel-report">
      <h2>③ JavaScript API v3 によるレポート埋め込み</h2>
      <div class="controls">
        <select id="report-select"><option value="">読み込み中...</option></select>
        <button id="btn-report" class="primary">埋め込む</button>
      </div>
      <div id="report-container" class="embed-container"></div>
    </section>

    <!-- ④ ストーリー -->
    <section class="panel" id="panel-story">
      <h2>④ REST API によるストーリー埋め込み</h2>
      <p>ストーリー本文を REST API で取得し、このページ内に描画します。</p>
      <div class="controls">
        <select id="story-select"><option value="">読み込み中...</option></select>
        <button id="btn-story" class="primary">表示</button>
      </div>
      <div id="story-container" class="embed-container story"></div>
    </section>

    <!-- ⑤ シグナル -->
    <section class="panel" id="panel-signal">
      <h2>⑤ REST API によるシグナル埋め込み</h2>
      <p>Yellowfin が自動検知したシグナル(データの変化・外れ値など)を REST API で取得して一覧表示します。</p>
      <button id="btn-signal" class="primary">シグナルを取得</button>
      <div id="signal-container" class="embed-container"></div>
    </section>
  </main>
  </div><!-- /#app-body -->

  <!-- JS API v3 のスクリプトは app.js が公開設定取得後に動的に読み込む -->
  <script src="app.js"></script>
</body>
</html>

app/public/app.js(★フロント:JS API 埋め込み)

ここがブラウザ側の肝です。JS API で重要なのは yellowfin.newSession(token) です。
loadDashboard() にトークンを直接渡すのではなく、先に newSession() で SSO トークンを使ってセッションを確立してから loadDashboard() / loadReport() を呼びます(これをやらないと、埋め込み枠の中に Yellowfin のログイン画面が出ます。ハマりどころで後述)。

ストーリー(④)は、本文を表示する前にも ensureSession() を呼んでいます。本文(StoryBody.i4)のレンダリングに Yellowfin のセッションが必要なためです。

app/public/app.js
'use strict';

/**
 * フロント側ロジック
 *  - /api/config から「ブラウザから見た Yellowfin URL」を取得
 *  - その URL から JS API v3 スクリプト(/JsAPI/v3)を動的に読み込み yellowfin.init()
 *  - 各タブで SSO トークンを使ってダッシュボード/レポートを埋め込み、
 *    ストーリー/シグナルは REST(プロキシ) 経由で描画する
 */

// ===========================================================================
// 設定の集約ポイント(Yellowfin バージョン差はまずここを調整)
//   JS API v3 のロード関数に SSO ログイントークンをどう渡すか、引数名は何か、を集約。
// ===========================================================================
const JSAPI = {
  scriptPath: '/JsAPI/v3',
  // ダッシュボード/レポートを指す ID の引数名(JS API v3)
  dashboardIdKey: 'dashboardUUID',
  reportIdKey: 'reportUUID',
};

let config = null;
let currentUser = null; // ログイン中のホストアプリユーザー
let ssoToken = null; // SSO タブで取得・表示するトークン
let sessionEstablished = false; // JS API セッションを newSession で確立済みか
let jsApiReady = false;

// --- 小物 ------------------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const el = (sel) => document.querySelector(sel);

async function api(path, opts) {
  const res = await fetch(path, opts);
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
  return data;
}

function setSsoStatus(state, text) {
  const badge = $('#sso-status');
  badge.className = 'badge ' + (state === 'ok' ? 'badge-ok' : state === 'err' ? 'badge-err' : 'badge-wait');
  badge.textContent = 'SSO: ' + text;
}

// --- 初期化 ----------------------------------------------------------------
async function boot() {
  setupTabs();
  config = await api('/api/config');
  wireLogin();
  wireButtons();

  // 既存セッションがあればそのままアプリ表示、無ければログイン画面
  try {
    currentUser = await api('/api/me');
    await enterApp();
  } catch {
    showLogin();
  }
}

function showLogin() {
  resetEmbeds(); // 前のユーザーの表示内容が残らないようにする
  $('#login-screen').hidden = false;
  $('#app-body').hidden = true;
  $('#user-area').hidden = true;
  setSsoStatus('wait', '未ログイン');
}

// 埋め込み済みのコンテンツ・選択状態・SSO 出力をすべて初期化し、タブを①に戻す
function resetEmbeds() {
  ['#dashboard-container', '#report-container', '#story-container', '#signal-container'].forEach((sel) => {
    const el = document.querySelector(sel);
    if (el) { el.innerHTML = ''; el.style.height = ''; } // 自動調整した高さも戻す
  });
  const out = $('#sso-out');
  if (out) out.textContent = '(ここに結果が表示されます)';
  // ドロップダウンも読み込み中表示に戻す(ログイン後に再取得される)
  ['#dashboard-select', '#report-select', '#story-select'].forEach((sel) => {
    const el = document.querySelector(sel);
    if (el) el.innerHTML = '<option value="">読み込み中...</option>';
  });
  // タブを ① SSO に戻す
  document.querySelectorAll('.tab').forEach((b) => b.classList.remove('active'));
  document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
  const tab = document.querySelector('.tab[data-tab="sso"]');
  const panel = $('#panel-sso');
  if (tab) tab.classList.add('active');
  if (panel) panel.classList.add('active');
}

// ログイン成功後に呼ぶ:本体表示・JS API初期化・一覧ロード
async function enterApp() {
  $('#login-screen').hidden = true;
  $('#app-body').hidden = false;
  $('#user-area').hidden = false;
  $('#user-name').textContent = currentUser.displayName + ' → YF: ' + currentUser.yfUser;
  setSsoStatus('wait', 'ログイン済み(トークン未取得)');

  // JS API v3 スクリプトを Yellowfin サーバから読み込む(初回のみ)
  if (!jsApiReady) {
    try {
      await loadScript(config.yfPublicUrl + JSAPI.scriptPath);
      if (typeof window.yellowfin !== 'undefined') {
        await window.yellowfin.init();
        jsApiReady = true;
        console.log('Yellowfin JS API v3 initialized');
      } else {
        console.error('yellowfin オブジェクトが見つかりません。JS API v3 の読み込みに失敗しました。');
      }
    } catch (e) {
      console.error('JS API ロード失敗:', e.message);
    }
  }

  // 一覧をプリロード(失敗してもタブは使える)
  loadDashboardList();
  loadReportList();
  loadStoryList();
}

function wireLogin() {
  $('#login-form').addEventListener('submit', async (ev) => {
    ev.preventDefault();
    const errBox = $('#login-error');
    errBox.textContent = '';
    try {
      currentUser = await api('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          username: $('#login-username').value,
          password: $('#login-password').value,
        }),
      });
      ssoToken = null; // ユーザーが変わったので取り直す
      sessionEstablished = false;
      await enterApp();
    } catch (e) {
      errBox.textContent = e.message;
    }
  });

  $('#btn-logout').addEventListener('click', async () => {
    // 1) ブラウザの Yellowfin JS API セッションを終了
    if (sessionEstablished && window.yellowfin && typeof window.yellowfin.logoff === 'function') {
      await new Promise((resolve) => {
        let done = false;
        const fin = () => { if (!done) { done = true; resolve(); } };
        try { window.yellowfin.logoff(fin); } catch { fin(); }
        setTimeout(fin, 3000); // 応答が無くても先へ進む
      });
    }
    // 2) サーバ側でログイントークンを失効 + アプリセッション破棄
    await api('/api/logout', { method: 'POST' }).catch(() => {});
    currentUser = null;
    ssoToken = null;
    sessionEstablished = false;
    showLogin();
  });
}

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.onload = resolve;
    s.onerror = () => reject(new Error('script load failed: ' + src));
    document.head.appendChild(s);
  });
}

// --- タブ切替 --------------------------------------------------------------
function setupTabs() {
  document.querySelectorAll('.tab').forEach((btn) => {
    btn.addEventListener('click', () => {
      document.querySelectorAll('.tab').forEach((b) => b.classList.remove('active'));
      document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
      btn.classList.add('active');
      $('#panel-' + btn.dataset.tab).classList.add('active');
    });
  });
}

// --- JS API セッション確立(必要時に1回) ---------------------------------
// サーバから SSO トークンを取得し、yellowfin.newSession(token) でセッションを張る。
// 以降の loadDashboard / loadReport はこのセッションで動くため token は渡さない。
async function ensureSession() {
  if (sessionEstablished) return;
  const data = await api('/api/sso/token', { method: 'POST' });
  const token = extractToken(data.token);
  await window.yellowfin.newSession(token);
  sessionEstablished = true;
  setSsoStatus('ok', '接続済み (' + (data.yfUser || 'user') + ')');
}

// REST レスポンスからトークン文字列を取り出す(構造差を吸収)
function extractToken(t) {
  if (typeof t === 'string') return t;
  return t?.securityToken || t?.token || t?.loginSessionId || JSON.stringify(t);
}

// --- ボタン配線 ------------------------------------------------------------
function wireButtons() {
  $('#btn-sso').addEventListener('click', async () => {
    const out = $('#sso-out');
    out.textContent = '取得中...';
    try {
      const data = await api('/api/sso/token', { method: 'POST' });
      ssoToken = extractToken(data.token);
      setSsoStatus('ok', '接続済み (' + (data.yfUser || 'user') + ')');
      out.textContent =
        'ログインユーザー: ' + currentUser.username + ' (' + currentUser.displayName + ')\n' +
        'マッピング先 YF ユーザー: ' + data.yfUser + '\n' +
        'tokenType: ' + data.tokenType + '\n' +
        'token: ' + ssoToken + '\n\n' +
        '※ このトークンはサーバが REST API で発行。あなたのパスワードも管理者資格情報も\n' +
        '   ブラウザ/Yellowfin には渡していません(マッピングしたユーザー名のみ使用)。';
    } catch (e) {
      setSsoStatus('err', '失敗');
      out.textContent = 'エラー: ' + e.message;
    }
  });

  $('#btn-dashboard').addEventListener('click', () => embedDashboard());
  $('#btn-report').addEventListener('click', () => embedReport());
  $('#btn-story').addEventListener('click', () => showStory());
  $('#btn-signal').addEventListener('click', () => showSignals());
}

// --- 一覧ロード(ドロップダウン) -----------------------------------------
async function fillSelect(selectSel, items, labelFn) {
  const sel = $(selectSel);
  sel.innerHTML = '';
  if (!items.length) {
    sel.innerHTML = '<option value="">(コンテンツがありません)</option>';
    return;
  }
  for (const it of items) {
    const opt = document.createElement('option');
    opt.value = it.id;
    opt.textContent = labelFn(it);
    sel.appendChild(opt);
  }
}

async function loadDashboardList() {
  try {
    const items = await api('/api/dashboards');
    fillSelect('#dashboard-select', items, (d) => d.name);
  } catch (e) {
    fillSelect('#dashboard-select', [], () => '');
    console.warn('dashboards:', e.message);
  }
}
async function loadReportList() {
  try {
    const items = await api('/api/reports');
    fillSelect('#report-select', items, (r) => r.name);
  } catch (e) {
    fillSelect('#report-select', [], () => '');
    console.warn('reports:', e.message);
  }
}
async function loadStoryList() {
  try {
    const items = await api('/api/stories');
    fillSelect('#story-select', items, (s) => s.name);
  } catch (e) {
    fillSelect('#story-select', [], () => '');
    console.warn('stories:', e.message);
  }
}

// コンテナ内の実コンテンツの最下端を測る(getBoundingClientRect はクロスブラウザで安定)。
function measureContentHeight(container) {
  const top = container.getBoundingClientRect().top;
  let maxBottom = 0;
  container.querySelectorAll('*').forEach((el) => {
    if (el.children.length === 0 || ['IMG', 'CANVAS', 'SVG'].includes(el.tagName)) {
      const r = el.getBoundingClientRect();
      if (r.height > 0 && r.width > 0) maxBottom = Math.max(maxBottom, r.bottom - top);
    }
  });
  return Math.ceil(maxBottom);
}

// 埋め込んだダッシュボードの自然な高さにコンテナを合わせる(内部スクロール/はみ出しを無くす)。
// 既定の小さい高さのまま描画させ、はみ出した分から自然なコンテンツ高さを得て、その高さに広げる。
// ※ overflow:visible の scrollHeight はブラウザ差がある(Firefox は overflow 分を含めない)ため、
//    計測中だけ overflow:hidden にして scrollHeight を正しく取得する。保険で葉要素の最下端も併用。
async function fitEmbedHeight(container) {
  const prevOverflow = container.style.overflow;
  container.style.overflow = 'hidden';
  let h = 0;
  for (let i = 0; i < 5; i++) {
    await new Promise((r) => setTimeout(r, 600));
    h = Math.max(h, container.scrollHeight, measureContentHeight(container));
  }
  if (h) {
    container.style.height = (h + 8) + 'px';
    // 広げた後に再フローしてまだはみ出る場合は1度だけ補正
    await new Promise((r) => setTimeout(r, 500));
    const need = Math.max(container.scrollHeight, measureContentHeight(container));
    if (need > container.clientHeight + 8) container.style.height = (need + 8) + 'px';
  }
  container.style.overflow = prevOverflow;
}

// --- ② ダッシュボード埋め込み (JS API v3) ---------------------------------
async function embedDashboard() {
  const id = $('#dashboard-select').value;
  const container = $('#dashboard-container');
  if (!id) { container.textContent = 'ダッシュボードを選択してください'; return; }
  container.innerHTML = '<div class="loading">読み込み中...</div>';
  try {
    await ensureSession();
    container.innerHTML = '';
    container.style.height = ''; // 既定(min-height)で描画 → scrollHeight で自然高さを得る
    const opts = { element: container };
    opts[JSAPI.dashboardIdKey] = id;
    await window.yellowfin.loadDashboard(opts);
    await fitEmbedHeight(container);
  } catch (e) {
    container.style.height = '';
    container.innerHTML = '<div class="err-box">埋め込み失敗: ' + e.message + '</div>';
  }
}

// --- ③ レポート埋め込み (JS API v3) ---------------------------------------
// 単一レポート(チャート等)はコンテナ高さに合わせて伸縮するため、
// ダッシュボードのような自動フィットではなく、見やすい固定高さを与える。
const REPORT_HEIGHT = 600;
async function embedReport() {
  const id = $('#report-select').value;
  const container = $('#report-container');
  if (!id) { container.textContent = 'レポートを選択してください'; return; }
  container.innerHTML = '<div class="loading">読み込み中...</div>';
  try {
    await ensureSession();
    container.innerHTML = '';
    container.style.height = REPORT_HEIGHT + 'px';
    const opts = { element: container };
    opts[JSAPI.reportIdKey] = id;
    await window.yellowfin.loadReport(opts);
  } catch (e) {
    container.style.height = '';
    container.innerHTML = '<div class="err-box">埋め込み失敗: ' + e.message + '</div>';
  }
}

// --- ④ ストーリー埋め込み (REST) ------------------------------------------
// バックエンドが REST API でストーリー本文(HTML)を取得して返すので、それを iframe で表示する。
async function showStory() {
  const id = $('#story-select').value;
  const container = $('#story-container');
  if (!id) { container.textContent = 'ストーリーを選択してください'; return; }
  container.innerHTML = '<div class="loading">読み込み中...</div>';
  try {
    // 本文(StoryBody.i4)のレンダリングには Yellowfin のセッションが要るため、先に確立する。
    // localhost:3000 と :8080 は同一サイト扱いのため、確立したセッション Cookie は iframe にも流れる。
    await ensureSession();
  } catch (e) {
    container.innerHTML = '<div class="err-box">SSO セッション確立に失敗: ' + e.message + '</div>';
    return;
  }
  const iframe = document.createElement('iframe');
  iframe.className = 'story-frame';
  iframe.src = '/api/stories/' + encodeURIComponent(id) + '/body';
  container.innerHTML = '';
  container.appendChild(iframe);
}

// --- ⑤ シグナル埋め込み (REST) --------------------------------------------
async function showSignals() {
  const container = $('#signal-container');
  container.innerHTML = '<div class="loading">読み込み中...</div>';
  try {
    const items = await api('/api/signals');
    if (!items.length) {
      container.innerHTML = '<div class="info-box">シグナルがまだありません。' +
        'Yellowfin 側でシグナル分析ジョブを実行すると生成されます。</div>';
      return;
    }
    container.innerHTML = '';
    for (const s of items) {
      const card = document.createElement('div');
      card.className = 'signal-card';
      card.innerHTML =
        '<h4>' + escapeHtml(s.title) + '</h4>' +
        (s.date ? '<div class="signal-date">' + escapeHtml(String(s.date)) + '</div>' : '') +
        (s.value ? '<div class="signal-value">' + escapeHtml(s.value) + '</div>' : '');
      container.appendChild(card);
    }
  } catch (e) {
    container.innerHTML = '<div class="err-box">取得失敗: ' + e.message + '</div>';
  }
}

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, (c) => (
    { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
  ));
}

// 起動
boot().catch((e) => {
  console.error(e);
  setSsoStatus('err', '初期化失敗');
});

app/public/styles.css

最小限のスタイルです(本筋ではないので折りたたみ感覚でどうぞ)。

app/public/styles.css
* { box-sizing: border-box; }
/* hidden 属性は常に最優先で非表示にする(.login-screen 等の display 指定に勝たせる) */
[hidden] { display: none !important; }
body {
  margin: 0;
  font-family: -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
  color: #1f2933;
  background: #f4f6f8;
}
header {
  background: #1b2733;
  color: #fff;
  padding: 18px 24px;
  position: relative;
}
header h1 { margin: 0 0 4px; font-size: 20px; }
header .sub { margin: 0; font-size: 13px; color: #9fb3c8; }
.header-right {
  position: absolute; top: 16px; right: 24px;
  display: flex; align-items: center; gap: 14px;
}
.badge {
  padding: 5px 12px; border-radius: 14px; font-size: 12px; font-weight: 600;
}
.badge-wait { background: #52606d; color: #cbd2d9; }
.badge-ok   { background: #2f9e44; color: #fff; }
.badge-err  { background: #e03131; color: #fff; }

.user-area { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #cbd2d9; }
.link-btn {
  background: none; border: 1px solid #52606d; color: #cbd2d9;
  padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
}
.link-btn:hover { background: #2d3a47; }

/* ログイン画面 */
.login-screen {
  display: flex; justify-content: center; align-items: flex-start;
  padding: 60px 20px; min-height: 70vh;
}
.login-card {
  background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
  padding: 28px 32px; width: 100%; max-width: 380px;
  box-shadow: 0 4px 16px rgba(0,0,0,.06);
}
.login-card h2 { margin: 0 0 6px; font-size: 18px; }
.login-note { font-size: 13px; color: #52606d; line-height: 1.6; margin: 0 0 18px; }
.login-card label { display: block; font-size: 13px; color: #3e4c59; margin-bottom: 12px; }
.login-card input {
  display: block; width: 100%; margin-top: 5px; padding: 9px 10px;
  border: 1px solid #cbd2d9; border-radius: 6px; font-size: 14px;
}
.login-card button.primary { width: 100%; margin-top: 6px; padding: 11px; }
.login-error { color: #b91c1c; font-size: 13px; margin-top: 10px; min-height: 18px; }
.login-hint { font-size: 12px; color: #7b8794; margin: 14px 0 0; }
.login-hint code { background: #f0f2f5; padding: 1px 6px; border-radius: 4px; }

.tabs {
  display: flex; gap: 2px; background: #fff;
  border-bottom: 1px solid #e4e7eb; padding: 0 16px; flex-wrap: wrap;
}
.tab {
  background: none; border: none; padding: 14px 16px; cursor: pointer;
  font-size: 14px; color: #52606d; border-bottom: 3px solid transparent;
}
.tab:hover { color: #1f2933; }
.tab.active { color: #1c64f2; border-bottom-color: #1c64f2; font-weight: 600; }

main { padding: 24px; max-width: 1100px; margin: 0 auto; }
.panel { display: none; }
.panel.active { display: block; }
.panel h2 { font-size: 17px; margin-top: 0; }
.panel p { font-size: 14px; line-height: 1.6; color: #3e4c59; }

.controls { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
select {
  padding: 8px 10px; border: 1px solid #cbd2d9; border-radius: 6px;
  font-size: 14px; min-width: 280px; background: #fff;
}
button.primary {
  background: #1c64f2; color: #fff; border: none; border-radius: 6px;
  padding: 9px 18px; font-size: 14px; cursor: pointer;
}
button.primary:hover { background: #1751c9; }

.out {
  background: #1b2733; color: #d9e2ec; padding: 14px; border-radius: 8px;
  font-size: 13px; white-space: pre-wrap; word-break: break-all; line-height: 1.5;
}
.embed-container {
  margin-top: 12px; min-height: 480px; background: #fff;
  border: 1px solid #e4e7eb; border-radius: 8px; padding: 4px;
}
.embed-container.story { padding: 20px; min-height: 300px; }
.loading { padding: 40px; text-align: center; color: #7b8794; }
.err-box { padding: 16px; color: #b91c1c; background: #fef2f2; border-radius: 6px; }
.info-box { padding: 16px; color: #1e429f; background: #ebf2ff; border-radius: 6px; }

.signal-card {
  border: 1px solid #e4e7eb; border-left: 4px solid #f59f00;
  border-radius: 6px; padding: 12px 16px; margin-bottom: 10px; background: #fff;
}
.signal-card h4 { margin: 0 0 4px; font-size: 15px; }
.signal-card .signal-date { font-size: 12px; color: #7b8794; margin-bottom: 6px; }
.signal-card p { margin: 0; font-size: 14px; }
.signal-card .signal-value { font-size: 18px; font-weight: 700; color: #2f9e44; }

.embed-container.story { padding: 0; }
.story-frame { width: 100%; height: 70vh; border: 0; display: block; }

ハマりどころ(実機で踏んだもの)

このサンプルを作る過程で実際に詰まった点をまとめます。同じことをやる方の参考になれば。

1. REST API のメディアタイプは「バージョン付き」

Accept: application/json で投げると {"code":400,"reason":"UNKNOWN_VERSION"} が返ります。
application/vnd.yellowfin.api-v4.2+json のようにバージョン付きのメディアタイプを指定する必要があります(バージョン番号は Yellowfin のリリースに対応)。Accept / Content-Type の両方に付けます。

2. パスワードなし SSO には DB 設定(SIMPLE_AUTHENTICATION)が必要

noPassword: true でログイントークンを発行しようとすると、初期状態では
{"code":400,"reason":"BAD_REQUEST","description":"UNSECURE_LOGON_NOT_ENABLED"} で弾かれます。
リポジトリDBの Configuration テーブルに SIMPLE_AUTHENTICATION=TRUE を入れ、Yellowfin を再起動すると通るようになります(設定がキャッシュされるため、再起動が必要)。
このサンプルではそれを scripts/enable-sso.sh で自動化しています。

※ この点は先人の記事に助けられました。

3. JS API は loadDashboard にトークンを渡すのではなく newSession でセッションを張る

yellowfin.loadDashboard({ ..., token }) のようにロード関数へトークンを渡しても認証されず、
埋め込み枠の中に Yellowfin のログイン画面が表示されてしまいます。
正しくは、先に await yellowfin.newSession(token) でセッションを確立し、その後で
yellowfin.loadDashboard({ dashboardUUID, element })(トークンなし)を呼びます。

4. コンテンツの一覧は { items: [...] }、ID は publish UUID

/api/dashboards などのレスポンスは items 配列です。JS API に渡す ID は
dashboardPublishUUID / reportPublishUUID、ストーリー本文の取得は storyUuid を使います。
シグナルは ?size= ではなく ?limit= が必須でした。

5. ストーリー本文の表示にはセッションが必要

/api/stories/{uuid}/body は署名付きの StoryBody.i4 へ 302 リダイレクトし、本文 HTML を返します。
ただし本文の描画には Yellowfin のセッションが要るため、表示前に newSession() でセッションを張っておきます。
localhost:3000(アプリ)と localhost:8080(Yellowfin)は同一サイト扱いなので、確立したセッション Cookie は iframe 内のリクエストにも流れます。

6. ライセンスも REST API で投入できる

all-in-one イメージは「ライセンスは Web UI から投入」と案内されていますが、
POST /api/rpc/licence-management/upload-licence(フォーム項目 newLicence)でREST 経由でも投入できます。
しかもライセンス未投入の状態でも管理者の access トークンは取得できるので、これで起動を完全自動化できました。

7. all-in-one はデータ非永続

このイメージは停止するとデータが消えます。docker compose down で作り直したら、
ライセンス投入と SSO 許可をやり直す必要があります(だから start.sh 一発で再現できるようにしています)。
restart(再起動)ではデータは消えません。

8. 起動がタイムアウトするとき

別の環境で「起動がタイムアウトした」という報告がありました。start.sh / start.bat の
タイムアウトは「docker compose up -d の後で、Yellowfin がログイン画面を返すまで待つ」段階で
出ます(イメージ pull はこの待ち時間に含まれません)。原因の多くは次の3つです。

  • Docker のメモリ不足(最有力): Yellowfin は 4GB 必要。Docker Desktop の Memory を
    6GB 以上にする。少ないと JVM が起動できず立ち上がりません。
  • 初回ブートが遅い: 遅いマシン/ディスク、Apple Silicon の amd64 エミュレーション等。
    WAIT_MINUTES=45 ./start.sh で待ち時間を延ばせます。
  • ポート競合: 8080 / 3000 が使用中。前述の .env で別ポートに変更します。

切り分けには docker compose ps(Exited か)/ docker compose logs --tail=80 yellowfin
(OOM 等)/ docker stats が有効です(スクリプトはタイムアウト時にこれらを自動表示します)。

9. 古い Yellowfin イメージのキャッシュ

docker compose up は :latest がローカルにあれば再 pull せず使い回します。古い :latest が
残っていると、本サンプル前提の 9.17 / api-v4.2 と食い違い、主にライセンス投入や SSO で失敗します。
そこで docker-compose.yml ではタグを 9.17 に固定しています(.env の YF_IMAGE_TAG で変更可)。
固定タグなら各環境の古い :latest に左右されず、確実に同じ版が取得されます。


まとめ

  • インストール直後の Yellowfin に対して、SSO・ダッシュボード・レポート・ストーリー・シグナルの5つの埋め込みを、Docker 一式 + ワンショット起動スクリプトで再現できるサンプルを作りました。
  • SSO は「ユーザーのパスワードを Yellowfin に渡さない」標準的な形(noPassword)で実装しています。
  • 詰まりやすいのは、メディアタイプ・SIMPLE_AUTHENTICATION・newSession の3点です。

埋め込みの「最初の一歩」として、そのまま使える形を目指しました。
どなたかの参考になれば幸いです。


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?