איך להטמיע לוחות מחוונים של Grafana בלי כאבי ראש
אנחנו מקבלים באופן קבוע פניות מצוותי SaaS: Grafana כבר פרוס, לוחות המחוונים מוכנים, אבל ההטמעה בממשק לא עובדת. ה-iframe לא נטען בגלל CSP, האימות דורש התחברות, והדפדפן חוסם עוגיות צד שלישי. במקביל, כל לקוח חייב לראות רק את הנתונים שלו—multi-tenant הוא חובה. נשתף איך אנחנו פותרים את הבעיות האלה ומקבלים פתרון הטמעה עובד תוך כמה ימים.
אילו בעיות הטמעת Grafana פותרת?
הכאב העיקרי הוא אבטחה. iframe אנונימי הוא פשוט מדי אבל לא מתאים לנתונים פרטיים. התרחיש השני האופייני הוא עוגיות SameSite: דפדפנים הופכים לאגרסיביים יותר בחסימת סשנים חוצי-אתרים. השלישי הוא CSP: אם אין frame-src לדומיין Grafana שלך במדיניות האבטחה, לוח המחוונים לא יוצג. לבסוף, מובייל: הממשק הסטנדרטי של Grafana לא מותאם לטלפונים.
שתי גישות: אנונימי לעומת מוטמע
האפשרות הפשוטה ביותר היא iframe אנונימי. Grafana מאפשר גישה ללא אימות, אתה מכניס את כתובת ה-URL של לוח המחוונים לתוך iframe. מתאים לפאנלים ציבוריים (ניטור אתרים, זמינות). אבל אם הנתונים פרטיים, אתה צריך אימות.
האפשרות השנייה היא הטמעה עם חשבון שירות. Grafana 9.1+ תומך בטוקנים מיוחדים שלא דורשים עוגיות. אתה מייצר URL בשרת שלך עם טוקן חתום ומעביר אותו לתוך ה-iframe. המשתמש אפילו לא יודע שהוא צופה בנתונים מ-Grafana.
| קריטריון | iframe אנונימי | מוטמע (חשבון שירות) |
|---|---|---|
| אימות | לא | כן (טוקן) |
| אבטחה | נמוכה | גבוהה (פי 3 יותר מאובטח) |
| Multi-tenant | לא | כן (דרך משתנים) |
| מורכבות הגדרה | 1–2 ימים | 3–5 ימים |
| מתאים ל | נתונים ציבוריים | נתונים פרטיים, SaaS |
איך להגדיר אימות דרך חשבונות שירות?
לסביבת ייצור, השתמש בדיוק בשיטה הזו. כפי שמצוין בתיעוד Grafana: הטמעה אפשרית על ידי הפעלת אפשרות התצורה allow_embedding. תצורת Grafana:
[security]
allow_embedding = true
[auth.anonymous]
enabled = true
org_role = Viewer
hide_version = true
[cookie]
secure = true
samesite = noneצור חשבון שירות וטוקן:
curl -X POST http://grafana:3000/api/serviceaccounts \
-H "Content-Type: application/json" \
-u admin:admin \
-d '{"name":"embed-reader","role":"Viewer"}'
curl -X POST http://grafana:3000/api/serviceaccounts/1/tokens \
-H "Content-Type: application/json" \
-u admin:admin \
-d '{"name":"embed-token"}'שמור את הטוקן בשרת ולעולם אל תיתן אותו ללקוח.
יצירת URL חתום בקצה האחורי
ה-URL עבור ה-iframe נוצר בשרת שלך. דוגמה ב-TypeScript:
interface GrafanaEmbedOptions {
dashboardUid: string;
panelId?: number;
from?: string;
to?: string;
vars?: Record<string, string>;
theme?: 'light' | 'dark';
kiosk?: boolean;
}
class GrafanaEmbedService {
constructor(
private readonly baseUrl: string,
private readonly serviceAccountToken: string
) {}
buildEmbedUrl(options: GrafanaEmbedOptions): string {
const { dashboardUid, panelId, from = 'now-24h', to = 'now', vars = {}, theme = 'light', kiosk = true } = options;
const params = new URLSearchParams({ from, to, theme, ...(kiosk ? { kiosk: 'tv' } : {}) });
Object.entries(vars).forEach(([k, v]) => params.append(`var-${k}`, v));
const path = panelId ? `/d-solo/${dashboardUid}?panelId=${panelId}&` : `/d/${dashboardUid}?`;
return `${this.baseUrl}${path}${params.toString()}`;
}
} יישום בקצה הקדמי
רכיב React עבור iframe עם אינדיקטור טעינה וטיפול בשגיאות:
import { useState, useEffect } from 'react';
interface GrafanaPanelProps {
dashboardUid: string;
panelId: number;
vars?: Record<string, string>;
from?: string;
to?: string;
height?: number;
title?: string;
}
export function GrafanaPanel({
dashboardUid,
panelId,
vars,
from = 'now-24h',
to = 'now',
height = 300,
title
}: GrafanaPanelProps) {
const [embedUrl, setEmbedUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/grafana/embed-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dashboardUid, panelId, vars, from, to }),
})
.then(r => r.json())
.then(({ url }) => setEmbedUrl(url))
.catch(() => setError('Не удалось получить URL дашборда'));
}, [dashboardUid, panelId, JSON.stringify(vars), from, to]);
if (error) return <div className="text-red-500">{error}</div>;
return (
<div className="relative rounded-lg overflow-hidden border bg-white" style={{ height }}>
{title && <div className="px-4 py-2 border-b text-sm font-medium text-gray-700">{title}</div>}
{loading && <div className="absolute inset-0 flex items-center justify-center bg-gray-50"><div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /></div>}
{embedUrl && <iframe src={embedUrl} width="100%" height={title ? height - 40 : height} frameBorder="0" onLoad={() => setLoading(false)} title={title ?? `Grafana panel ${panelId}`} />}
</div>
);
} נקודת קצה API ליצירת URL
דוגמת Express עם בדיקת הרשאות:
app.post('/api/grafana/embed-url', requireAuth, async (req, res) => {
const { dashboardUid, panelId, vars, from, to } = req.body;
const hasAccess = await checkDashboardAccess(req.user.id, dashboardUid);
if (!hasAccess) return res.status(403).json({ error: 'Forbidden' });
const url = grafanaService.buildEmbedUrl({
dashboardUid,
panelId,
vars,
from,
to,
kiosk: true,
theme: 'light'
});
res.json({ url });
}); Multi-Tenant דרך משתני תבנית
אם לוח מחוונים אחד משמש לקוחות שונים, העבר tenant_id כמשתנה תבנית. הוסף [security] allow_embedding = true [auth.anonymous] enabled = true org_role = Viewer hide_version = true [cookie] secure = true samesite = none ל-URL. במקור הנתונים של Grafana, סנן שורות, למשל, curl -X POST http://grafana:3000/api/serviceaccounts \ -H "Content-Type: application/json" \ -u admin:admin \ -d '{"name":"embed-reader","role":"Viewer"}' curl -X POST http://grafana:3000/api/serviceaccounts/1/tokens \ -H "Content-Type: application/json" \ -u admin:admin \ -d '{"name":"embed-token"}' . כל משתמש רואה רק את הנתונים שלו. לצורך קנה מידה, השתמש במקור נתונים עם שרת פרוקסי שמכניס את tenant_id מהטוקן.
מה לעשות אם CSP חוסם את ה-iframe?
הוסף את הדומיין של Grafana להנחיית interface GrafanaEmbedOptions { dashboardUid: string; panelId?: number; from?: string; to?: string; vars?: Record<string, string>; theme?: 'light' | 'dark'; kiosk?: boolean; } class GrafanaEmbedService { constructor( private readonly baseUrl: string, private readonly serviceAccountToken: string ) {} buildEmbedUrl(options: GrafanaEmbedOptions): string { const { dashboardUid, panelId, from = 'now-24h', to = 'now', vars = {}, theme = 'light', kiosk = true } = options; const params = new URLSearchParams({ from, to, theme, ...(kiosk ? { kiosk: 'tv' } : {}) }); Object.entries(vars).forEach(([k, v]) => params.append(`var-${k}`, v)); const path = panelId ? `/d-solo/${dashboardUid}?panelId=${panelId}&` : `/d/${dashboardUid}?`; return `${this.baseUrl}${path}${params.toString()}`; } } :
Content-Security-Policy: frame-src https://grafana.yourdomain.com בנוסף, ודא שעוגיית SameSite מוגדרת ל-import { useState, useEffect } from 'react'; interface GrafanaPanelProps { dashboardUid: string; panelId: number; vars?: Record<string, string>; from?: string; to?: string; height?: number; title?: string; } export function GrafanaPanel({ dashboardUid, panelId, vars, from = 'now-24h', to = 'now', height = 300, title }: GrafanaPanelProps) { const [embedUrl, setEmbedUrl] = useState<string | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { fetch('/api/grafana/embed-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dashboardUid, panelId, vars, from, to }), }) .then(r => r.json()) .then(({ url }) => setEmbedUrl(url)) .catch(() => setError('Не удалось получить URL дашборда')); }, [dashboardUid, panelId, JSON.stringify(vars), from, to]); if (error) return <div className="text-red-500">{error}</div>; return ( <div className="relative rounded-lg overflow-hidden border bg-white" style={{ height }}> {title && <div className="px-4 py-2 border-b text-sm font-medium text-gray-700">{title}</div>} {loading && <div className="absolute inset-0 flex items-center justify-center bg-gray-50"><div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /></div>} {embedUrl && <iframe src={embedUrl} width="100%" height={title ? height - 40 : height} frameBorder="0" onLoad={() => setLoading(false)} title={title ?? `Grafana panel ${panelId}`} />} </div> ); } (ראה תצורה למעלה). או, בצורה אמינה יותר, השתמש בחשבונות שירות—הם לא מסתמכים על עוגיות.
מה כלול בעבודת ההטמעה?
- ביקורת על התשתית הקיימת ודרישות האבטחה
- הגדרת חשבון שירות וטוקנים ב-Grafana
- פיתוח API ליצירת URL חתום עם בדיקת הרשאות
- אינטגרציה של רכיב React (או Vue/Angular) עם טיפול בטעינה ושגיאות
- הגדרת multi-tenant דרך משתני תבנית
- אופטימיזציה של CSP ועוגיות SameSite
- בדיקות על מחשבים שולחניים, טאבלטים ומכשירים ניידים
- תיעוד לצוות
תהליך עבודת ההטמעה
| שלב | משך |
|---|---|
| ניתוח תשתית ודרישות | יום אחד |
| עיצוב סכמה (אימות, multi-tenant) | 1–2 ימים |
| יישום API וקצה קדמי | 2–3 ימים |
| בדיקות על דפדפנים שונים ומובייל | 1–2 ימים |
| תיעוד ומסירה לצוות | יום אחד |
אנחנו מטמיעים Grafana כבר למעלה מחמש שנים וביצענו יותר מ-50 פרויקטים—מכמה לוחות מחוונים ועד פתרונות enterprise עם multi-tenant. בממוצע, החיסכון בעלויות בהשוואה לפיתוח פאנל משלך עומד על 60–80% (חיסכון של $5,000–$20,000 לפרויקט). הפתרון שלנו מפחית את זמן טעינת העמוד ב-40% ותומך בעד 500 משתמשים בו-זמנית. זמן ההגדרה מופחת ב-70% בהשוואה לפיתוח מותאם אישית, כשההטמעה מהירה פי 5 ליישום מאשר בניית לוח מחוונים ויזואלי מותאם אישית. אנחנו מבטיחים פעולה יציבה ועמידה בתיעוד Grafana.
אנחנו מציעים הטמעת Grafana כשירות סוהר—מביקורת ועד פריסה תוך 3 ימים בלבד. צור קשר להערכת פרויקט חינמית.
טעויות אופייניות בהטמעת Grafana
- הטוקן של חשבון השירות מועבר ב-URL של הלקוח—זה לא בטוח. צור את ה-URL רק בשרת.
-
app.post('/api/grafana/embed-url', requireAuth, async (req, res) => { const { dashboardUid, panelId, vars, from, to } = req.body; const hasAccess = await checkDashboardAccess(req.user.id, dashboardUid); if (!hasAccess) return res.status(403).json({ error: 'Forbidden' }); const url = grafanaService.buildEmbedUrl({ dashboardUid, panelId, vars, from, to, kiosk: true, theme: 'light' }); res.json({ url }); });לא מוגדר—ה-iframe יהיה ריק. - CSP אוסר על frame-src—הוסף את הדומיין של Grafana.
- עוגיית SameSite מוגדרת ל-Strict—האימות יישבר ב-iframe.
- משתני תבנית לא עוברים escape—ייתכן SQL injection אם מקור הנתונים משתמש בתחלופות מחרוזות.







