SiamCafe · Blog
Qwik Resumability กับ MLOps Workflow — วิธีสร้าง
บทความ

Qwik Resumability กับ MLOps Workflow — วิธีสร้าง

เผยแพร่ 28 พฤษภาคม 2569

Qwik กับ MLOps Dashboard

MLOps Dashboard เป็นเครื่องมือสำคัญสำหรับ ML Engineers ในการ Monitor Model Performance, Track Experiments, จัดการ Pipeline และดู Metrics ต่างๆ ปัญหาคือ Dashboard มักมี Components จำนวนมาก เช่น Charts, Tables, Metrics Cards, Log Viewers ทำให้โหลดช้าเมื่อใช้ Framework แบบ Hydration

Qwik แก้ปัญหานี้ด้วย Resumability JavaScript โหลดเฉพาะ Component ที่ User กำลัง Interact ทำให้ Dashboard ที่มี 50+ Components โหลดเร็วเท่ากับที่มี 5 Components เพราะ Initial JavaScript Bundle แทบเป็นศูนย์

สร้าง MLOps Dashboard ด้วย Qwik

// src/routes/dashboard/index.tsx
// MLOps Dashboard — Main Page
import { component$, useStore, useTask$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { ModelMetrics } from '~/components/model-metrics';
import { ExperimentTable } from '~/components/experiment-table';
import { PipelineStatus } from '~/components/pipeline-status';
import { DriftMonitor } from '~/components/drift-monitor';

// Server-side Data Loading
export const useModels = routeLoader$(async () => {
  const res = await fetch('http://mlflow:5000/api/2.0/mlflow/registered-models/list');
  return res.json();
});

export const useExperiments = routeLoader$(async () => {
  const res = await fetch('http://mlflow:5000/api/2.0/mlflow/experiments/search', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ max_results: 20 }),
  });
  return res.json();
});

export default component$(() => {
  const models = useModels();
  const experiments = useExperiments();

  const dashboard = useStore({
    activeTab: 'overview',
    refreshInterval: 30000,
    selectedModel: null as string | null,
  });

  return (
    <div class="min-h-screen bg-zinc-950 text-white">
      <header class="border-b border-zinc-800 px-6 py-4">
        <h1 class="text-2xl font-bold">MLOps Dashboard</h1>
        <nav class="flex gap-4 mt-3">
          {['overview', 'experiments', 'models', 'pipelines', 'monitoring'].map(
            (tab) => (
              <button
                key={tab}
                class={`px-4 py-2 rounded-lg text-sm font-medium transition
                  `}
                onClick$={() => { dashboard.activeTab = tab; }}
              >
                {tab.charAt(0).toUpperCase() + tab.slice(1)}
              </button>
            )
          )}
        </nav>
      </header>

      <main class="p-6">
        {dashboard.activeTab === 'overview' && (
          <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
            <MetricCard title="Active Models"
              value={models.value?.registered_models?.length || 0}
              trend="+2 this week" />
            <MetricCard title="Experiments"
              value={experiments.value?.experiments?.length || 0}
              trend="+5 this week" />
            <MetricCard title="Avg Latency"
              value="45ms" trend="-3ms" />
            <MetricCard title="Success Rate"
              value="99.2%" trend="+0.1%" />
          </div>
        )}

        {dashboard.activeTab === 'experiments' && (
          <ExperimentTable experiments={experiments.value} />
        )}

        {dashboard.activeTab === 'monitoring' && (
          <DriftMonitor modelName={dashboard.selectedModel} />
        )}

        {dashboard.activeTab === 'pipelines' && (
          <PipelineStatus />
        )}
      </main>
    </div>
  );
});

// Metric Card — โหลด JS เฉพาะเมื่อ Hover/Click
const MetricCard = component$<{
  title: string; value: string | number; trend: string;
}>((props) => {
  return (
    <div class="bg-zinc-900 rounded-xl p-5 border border-zinc-800
      hover:border-indigo-500/50 transition">
      <p class="text-sm text-zinc-400">{props.title}</p>
      <p class="text-3xl font-bold mt-1">{props.value}</p>
      <p class="text-sm text-emerald-400 mt-1">{props.trend}</p>
    </div>
  );
});

Model Monitoring Component

// src/components/drift-monitor.tsx
// Data Drift & Model Performance Monitor
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';

interface DriftData {
  feature: string;
  psi: number;          // Population Stability Index
  ks_stat: number;      // Kolmogorov-Smirnov Statistic
  status: 'stable' | 'warning' | 'drift';
  timestamp: string;
}

interface ModelPerf {
  accuracy: number;
  precision: number;
  recall: number;
  f1: number;
  latency_p50: number;
  latency_p99: number;
  throughput: number;
}

export const DriftMonitor = component$<{ modelName: string | null }>(
  (props) => {
    const driftData = useSignal<DriftData[]>([]);
    const perfData = useSignal<ModelPerf | null>(null);
    const isLoading = useSignal(true);

    // useVisibleTask$ — โหลดเฉพาะเมื่อ Component Visible
    useVisibleTask$(async ({ track, cleanup }) => {
      track(() => props.modelName);
      if (!props.modelName) return;

      const fetchData = async () => {
        try {
          const [driftRes, perfRes] = await Promise.all([
            fetch(`/api/monitoring/drift/`),
            fetch(`/api/monitoring/performance/`),
          ]);
          driftData.value = await driftRes.json();
          perfData.value = await perfRes.json();
        } catch (e) {
          console.error('Monitoring fetch error:', e);
        } finally {
          isLoading.value = false;
        }
      };

      await fetchData();
      const interval = setInterval(fetchData, 30000);
      cleanup(() => clearInterval(interval));
    });

    if (isLoading.value) {
      return <div class="text-zinc-400">Loading monitoring data...</div>;
    }

    return (
      <div class="space-y-6">
        <h2 class="text-xl font-semibold">
          Model: {props.modelName}
        </h2>

        {/* Performance Metrics */}
        {perfData.value && (
          <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
            <PerfCard label="Accuracy"
              value={`%`}
              threshold={0.9} current={perfData.value.accuracy} />
            <PerfCard label="F1 Score"
              value={perfData.value.f1.toFixed(3)}
              threshold={0.85} current={perfData.value.f1} />
            <PerfCard label="Latency P99"
              value={`ms`}
              threshold={100} current={perfData.value.latency_p99}
              inverse />
            <PerfCard label="Throughput"
              value={`/s`}
              threshold={100} current={perfData.value.throughput} />
          </div>
        )}

        {/* Drift Table */}
        <div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
          <div class="px-5 py-3 border-b border-zinc-800">
            <h3 class="font-medium">Feature Drift Detection</h3>
          </div>
          <table class="w-full text-sm">
            <thead class="text-zinc-400 border-b border-zinc-800">
              <tr>
                <th class="px-5 py-3 text-left">Feature</th>
                <th class="px-5 py-3 text-left">PSI</th>
                <th class="px-5 py-3 text-left">KS Stat</th>
                <th class="px-5 py-3 text-left">Status</th>
              </tr>
            </thead>
            <tbody>
              {driftData.value.map((d) => (
                <tr key={d.feature} class="border-b border-zinc-800/50">
                  <td class="px-5 py-3 font-mono">{d.feature}</td>
                  <td class="px-5 py-3">{d.psi.toFixed(4)}</td>
                  <td class="px-5 py-3">{d.ks_stat.toFixed(4)}</td>
                  <td class="px-5 py-3">
                    <span class={`px-2 py-1 rounded text-xs font-medium
                      `}>
                      {d.status.toUpperCase()}
                    </span>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    );
  }
);

const PerfCard = component$<{
  label: string; value: string;
  threshold: number; current: number; inverse?: boolean;
}>((props) => {
  const isGood = props.inverse
    ? props.current <= props.threshold
    : props.current >= props.threshold;

  return (
    <div class={`rounded-xl p-4 border `}>
      <p class="text-sm text-zinc-400">{props.label}</p>
      <p class="text-2xl font-bold mt-1">{props.value}</p>
    </div>
  );
});

MLOps API Routes

// src/routes/api/monitoring/drift/[model]/index.ts
// API Route — Drift Detection Data
import type { RequestHandler } from '@builder.io/qwik-city';

export const onGet: RequestHandler = async ({ params, json }) => {
  const modelName = params.model;

  // เรียก ML Monitoring Service
  const response = await fetch(
    `http://ml-monitor:8080/api/v1/drift/`,
    { headers: { 'Authorization': `Bearer ` } }
  );

  if (!response.ok) {
    json(response.status, { error: 'Failed to fetch drift data' });
    return;
  }

  const data = await response.json();
  json(200, data);
};

// src/routes/api/monitoring/performance/[model]/index.ts
export const onGet: RequestHandler = async ({ params, json }) => {
  const modelName = params.model;

  // Query Prometheus สำหรับ Model Metrics
  const queries = {
    accuracy: `ml_model_accuracy{model=""}`,
    latency_p50: `histogram_quantile(0.5, ml_model_latency_bucket{model=""})`,
    latency_p99: `histogram_quantile(0.99, ml_model_latency_bucket{model=""})`,
    throughput: `rate(ml_model_predictions_total{model=""}[5m])`,
  };

  const results: Record<string, number> = {};

  for (const [key, query] of Object.entries(queries)) {
    const res = await fetch(
      `http://prometheus:9090/api/v1/query?query=`
    );
    const data = await res.json();
    results[key] = parseFloat(data?.data?.result?.[0]?.value?.[1] || '0');
  }

  json(200, results);
};

// src/routes/api/pipelines/index.ts
// API Route — Pipeline Status
export const onGet: RequestHandler = async ({ json }) => {
  // Query Airflow/Kubeflow สำหรับ Pipeline Status
  const res = await fetch('http://airflow:8080/api/v1/dags', {
    headers: { 'Authorization': `Basic ` },
  });
  const data = await res.json();

  const pipelines = data.dags?.map((dag: any) => ({
    id: dag.dag_id,
    status: dag.is_paused ? 'paused' : 'active',
    lastRun: dag.last_parsed_time,
    schedule: dag.schedule_interval,
  })) || [];

  json(200, pipelines);
};

MLOps Workflow Automation

# mlops_workflow.py — MLOps Pipeline Automation
import mlflow
from mlflow.tracking import MlflowClient
import requests
import json
from datetime import datetime

class MLOpsWorkflow:
    """จัดการ MLOps Workflow"""

    def __init__(self, tracking_uri="http://mlflow:5000"):
        mlflow.set_tracking_uri(tracking_uri)
        self.client = MlflowClient()

    def train_and_log(self, model_name, train_func, params, X_train, y_train,
                      X_test, y_test):
        """Train Model และ Log ไป MLflow"""
        with mlflow.start_run(run_name=f"{model_name}_{datetime.now():%Y%m%d}"):
            # Log Parameters
            mlflow.log_params(params)

            # Train
            model = train_func(X_train, y_train, **params)

            # Evaluate
            predictions = model.predict(X_test)
            from sklearn.metrics import accuracy_score, f1_score
            accuracy = accuracy_score(y_test, predictions)
            f1 = f1_score(y_test, predictions, average='weighted')

            # Log Metrics
            mlflow.log_metrics({
                "accuracy": accuracy,
                "f1_score": f1,
                "train_size": len(X_train),
                "test_size": len(X_test),
            })

            # Log Model
            mlflow.sklearn.log_model(model, "model",
                registered_model_name=model_name)

            print(f"Model {model_name}: accuracy={accuracy:.4f} f1={f1:.4f}")
            return model

    def promote_model(self, model_name, version, stage="Production"):
        """Promote Model ไป Production"""
        self.client.transition_model_version_stage(
            name=model_name,
            version=version,
            stage=stage,
        )
        print(f"Model {model_name} v{version} promoted to {stage}")

    def check_drift(self, model_name, current_data, reference_data):
        """ตรวจจับ Data Drift"""
        from scipy.stats import ks_2samp
        import numpy as np

        drift_results = []
        for col in current_data.columns:
            stat, p_value = ks_2samp(
                reference_data[col].dropna(),
                current_data[col].dropna(),
            )
            # PSI Calculation
            psi = self._calculate_psi(
                reference_data[col].dropna().values,
                current_data[col].dropna().values,
            )

            status = "stable"
            if psi > 0.2 or p_value < 0.01:
                status = "drift"
            elif psi > 0.1 or p_value < 0.05:
                status = "warning"

            drift_results.append({
                "feature": col,
                "ks_stat": float(stat),
                "p_value": float(p_value),
                "psi": float(psi),
                "status": status,
            })

        return drift_results

    def _calculate_psi(self, expected, actual, bins=10):
        """คำนวณ Population Stability Index"""
        import numpy as np
        breakpoints = np.linspace(
            min(expected.min(), actual.min()),
            max(expected.max(), actual.max()),
            bins + 1,
        )
        expected_pct = np.histogram(expected, breakpoints)[0] / len(expected)
        actual_pct = np.histogram(actual, breakpoints)[0] / len(actual)
        expected_pct = np.clip(expected_pct, 0.001, None)
        actual_pct = np.clip(actual_pct, 0.001, None)
        psi = np.sum((actual_pct - expected_pct) *
                     np.log(actual_pct / expected_pct))
        return psi

# ตัวอย่าง
# workflow = MLOpsWorkflow()
# workflow.train_and_log("fraud_detector", train_xgb, params, X_train, y_train, X_test, y_test)
# workflow.promote_model("fraud_detector", version=3, stage="Production")

Qwik Resumability คืออะไร

Qwik ใช้ Resumability แทน Hydration JavaScript โหลดเฉพาะ Component ที่ User Interact Serialize State ใน HTML แล้ว Resume ทำให้ Initial Load เร็วมาก เหมาะกับ Dashboard ที่มี Components เยอะ