With the new HTML Widget it was never easier to build useful widgets, especially if you are using the AI Coding Assistant from the AI Agent Manager (now public preview
). However, not everyone can enable the AI Agent Manager because of company policies or data-protection concerns. Therefore I would like to use this thread to share the best creations.
Please use the following format:
**Prompt:** <<your prompt>>
**Screenshot:**
<<screenshot of the widget>>
<<optional-description>>
**Code:**
<details>
\```
\```
</details>
How to use shared widget:
Go to your dashboard, select the HTML Widget and enable the โadvanced developer modeโ. Then copy the code from the โdetailsโ view inside the code-text-input in the HTML Widget config view.
I will start with my Spark-Line-KPI-Widget
Prompt: Create a card layout showing all the latest measurements of the current device. The measurements should update automatically. Underneath the measurements, there should be a spark-line in different pastel colors showing the recent trends.
Screenshot:
Sadly the measurements in this example are simulated. But I like the โspark-linesโ which indicates the latest measurements trends.
Code:
const SPARKLINE_COLORS = [
โ#a8c9f8โ,
โ#f9bfa8โ,
โ#a8f0c6โ,
โ#d4a8f9โ,
โ#f9e8a8โ,
โ#a8f4f0โ,
โ#f9a8bbโ,
โ#c8e8a8โ,
โ#f9a8e0โ,
โ#a8c4f9โ,
โ#f9cda8โ,
โ#a8f9c8โ,
];
export default class CurrentMeasurementWidget extends LitElement {
static styles = css`
:host > div {
padding: 24px 28px 28px 28px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.widget-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--c8y-root-component-separator-color);
flex-shrink: 0;
}
.widget-title {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.widget-subtitle {
font-size: 12px;
color: var(--text-muted);
margin: 4px 0 0 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.last-updated {
font-size: 11px;
color: var(--text-muted);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
flex: 1;
align-content: start;
overflow-y: auto;
padding: 2px 0 4px 0;
}
.metric-card {
background-color: var(--body-background-color);
border: 1px solid var(--c8y-root-component-border-color);
border-radius: var(--c8y-root-component-border-radius-base);
padding: 16px 16px 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
transition: box-shadow 0.15s ease;
}
.metric-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
.metric-type {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metric-series {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metric-value-row {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
.metric-value {
font-size: 28px;
font-weight: 700;
color: var(--text-color);
line-height: 1;
}
.metric-unit {
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
.metric-time {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.sparkline-container {
margin-top: 10px;
position: relative;
height: 44px;
}
.sparkline-container svg {
width: 100%;
height: 44px;
display: block;
overflow: visible;
}
.sparkline-loading {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
}
.sparkline-no-data {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
font-size: 10px;
color: var(--text-muted);
}
.sparkline-range {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
margin-top: 2px;
}
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-state h4 {
margin: 0 0 6px 0;
font-size: 16px;
color: var(--text-color);
}
.empty-state p {
margin: 0;
font-size: 13px;
}
.error-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.refreshing-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #a8c9f8;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
.mini-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--c8y-root-component-border-color);
border-top-color: #a8c9f8;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.metric-type-row {
display: flex;
align-items: center;
overflow: hidden;
}
`;
static properties = {
c8yContext: { type: Object },
measurements: { type: Array },
sparklines: { type: Object },
loading: { type: Boolean },
error: { type: String },
lastUpdated: { type: String },
refreshing: { type: Boolean },
};
constructor() {
super();
this.measurements = ;
this.sparklines = {};
this.loading = true;
this.error = null;
this.lastUpdated = null;
this.refreshing = false;
this._refreshTimer = null;
this._colorMap = {};
this._colorIndex = 0;
}
_getColor(key) {
if (!this._colorMap[key]) {
this._colorMap[key] = SPARKLINE_COLORS[this._colorIndex % SPARKLINE_COLORS.length];
this._colorIndex++;
}
return this._colorMap[key];
}
async connectedCallback() {
super.connectedCallback();
await this.loadMeasurements();
this._refreshTimer = setInterval(() => this.refreshMeasurements(), 10000);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
}
async loadMeasurements() {
if (!this.c8yContext?.id) {
this.loading = false;
this.error = โNo device selectedโ;
return;
}
this.loading = true;
this.error = null;
try {
const data = await this._fetchLatestMeasurements(this.c8yContext.id);
data.forEach(m => this._getColor(`${m.fragment}__${m.series}`));
this.measurements = data;
this.lastUpdated = new Date().toLocaleTimeString();
await this.updateComplete;
this._loadAllSparklines();
} catch (err) {
console.error('Failed to load measurements:', err);
this.error = 'Failed to load measurements: ' + err.message;
} finally {
this.loading = false;
}
}
async refreshMeasurements() {
if (!this.c8yContext?.id || this.loading) return;
this.refreshing = true;
try {
const data = await this._fetchLatestMeasurements(this.c8yContext.id);
data.forEach(m => this._getColor(${m.fragment}__${m.series}));
this.measurements = data;
this.lastUpdated = new Date().toLocaleTimeString();
this._loadAllSparklines();
} catch (err) {
console.error(โRefresh failed:โ, err);
} finally {
this.refreshing = false;
}
}
async _loadAllSparklines() {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const dateFrom = oneHourAgo.toISOString();
const dateTo = now.toISOString();
const newSparklines = { ...this.sparklines };
const promises = this.measurements.map(async (m) => {
const key = `${m.fragment}__${m.series}`;
newSparklines[key] = { loading: true, points: null };
this.sparklines = { ...newSparklines };
try {
const points = await this._fetchSparklineData(
this.c8yContext.id,
m.type,
m.fragment,
m.series,
dateFrom,
dateTo
);
newSparklines[key] = { loading: false, points };
} catch (e) {
newSparklines[key] = { loading: false, points: [] };
}
this.sparklines = { ...newSparklines };
});
await Promise.all(promises);
}
async _fetchSparklineData(deviceId, type, fragment, series, dateFrom, dateTo) {
const url = /measurement/measurements?source=${deviceId}&type=${encodeURIComponent(type)}&dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}&pageSize=100&revert=false;
const response = await fetch(url);
if (!response.ok) return ;
const data = await response.json();
const measurements = data.measurements || ;
const points = [];
for (const m of measurements) {
if (m[fragment] && m[fragment][series] !== undefined) {
const val = parseFloat(m[fragment][series].value);
if (!isNaN(val)) {
points.push({ t: new Date(m.time).getTime(), v: val });
}
}
}
points.sort((a, b) => a.t - b.t);
return points;
}
async _fetchLatestMeasurements(deviceId) {
const response = await fetch(
/measurement/measurements?source=${deviceId}&pageSize=50&revert=true
);
if (!response.ok) {
throw new Error(HTTP ${response.status});
}
const data = await response.json();
const measurements = data.measurements || ;
const latestMap = new Map();
for (const m of measurements) {
for (const key of Object.keys(m)) {
if (key.startsWith('self') || key === 'id' || key === 'type' ||
key === 'source' || key === 'time') continue;
const fragment = m[key];
if (typeof fragment !== 'object' || fragment === null) continue;
for (const seriesKey of Object.keys(fragment)) {
const series = fragment[seriesKey];
if (typeof series !== 'object' || series === null) continue;
if (series.value === undefined) continue;
const mapKey = `${key}__${seriesKey}`;
if (!latestMap.has(mapKey)) {
latestMap.set(mapKey, {
type: m.type || key,
fragment: key,
series: seriesKey,
value: series.value,
unit: series.unit || '',
time: m.time,
});
}
}
}
}
return Array.from(latestMap.values());
}
_buildSparklineSvg(points) {
if (!points || points.length < 2) return null;
const W = 200;
const H = 40;
const PAD_X = 2;
const PAD_Y = 3;
const values = points.map(p => p.v);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rangeV = maxV - minV || 1;
const times = points.map(p => p.t);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const rangeT = maxT - minT || 1;
const toX = t => PAD_X + ((t - minT) / rangeT) * (W - PAD_X * 2);
const toY = v => PAD_Y + (1 - (v - minV) / rangeV) * (H - PAD_Y * 2);
const coords = points.map(p => ({ x: toX(p.t), y: toY(p.v) }));
let pathD = `M ${coords[0].x} ${coords[0].y}`;
for (let i = 1; i < coords.length; i++) {
const prev = coords[i - 1];
const curr = coords[i];
const cx = (prev.x + curr.x) / 2;
pathD += ` Q ${cx} ${prev.y} ${curr.x} ${curr.y}`;
}
const lastCoord = coords[coords.length - 1];
const firstCoord = coords[0];
const areaD = pathD +
` L ${lastCoord.x} ${H} L ${firstCoord.x} ${H} Z`;
return {
pathD,
areaD,
lastX: lastCoord.x,
lastY: lastCoord.y,
minV,
maxV,
W,
H,
};
}
_hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return rgba(${r}, ${g}, ${b}, ${alpha});
}
_renderSparkline(key, color) {
const spark = this.sparklines[key];
if (!spark || spark.loading) {
return html`
<div class="sparkline-loading">
<div class="mini-spinner"></div>
</div>
`;
}
if (!spark.points || spark.points.length < 2) {
return html`
<div class="sparkline-no-data">No history in last hour</div>
`;
}
const svg = this._buildSparklineSvg(spark.points);
if (!svg) return html``;
const gradientId = `spark-grad-${key.replace(/[^a-zA-Z0-9]/g, '_')}`;
const fillTop = this._hexToRgba(color, 0.4);
const fillBot = this._hexToRgba(color, 0.05);
return html`
<div class="sparkline-container">
<svg viewBox="0 0 ${svg.W} ${svg.H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="${gradientId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${fillTop}"/>
<stop offset="100%" stop-color="${fillBot}"/>
</linearGradient>
</defs>
<path
d="${svg.areaD}"
fill="url(#${gradientId})"
stroke="none"
/>
<path
d="${svg.pathD}"
fill="none"
stroke="${color}"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="${svg.lastX}"
cy="${svg.lastY}"
r="3"
fill="${color}"
/>
</svg>
</div>
<div class="sparkline-range">
<span>${this._formatValue(svg.minV)}</span>
<span>1h</span>
<span>${this._formatValue(svg.maxV)}</span>
</div>
`;
}
_formatValue(value) {
if (value === null || value === undefined) return โโโ;
const num = parseFloat(value);
if (isNaN(num)) return String(value);
if (Math.abs(num) >= 1000) return num.toFixed(0);
if (Math.abs(num) >= 100) return num.toFixed(1);
if (Math.abs(num) >= 10) return num.toFixed(2);
return num.toFixed(2);
}
_formatTime(isoString) {
if (!isoString) return โโ;
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
formatLabel(fragment) {
return fragment
.replace(/^c8y/, โโ)
.replace(/([A-Z])/g, โ $1โ)
.trim();
}
render() {
const deviceName = this.c8yContext?.name || โUnknown Deviceโ;
if (this.loading) {
return html`
<style>${styleImports}</style>
<div>
<div class="widget-header">
<div>
<p class="widget-title">Current Measurements</p>
<p class="widget-subtitle">${deviceName}</p>
</div>
</div>
<div class="loading-container">
<div class="spinner">
<div class="rect1"></div>
<div class="rect2"></div>
<div class="rect3"></div>
<div class="rect4"></div>
<div class="rect5"></div>
</div>
</div>
</div>
`;
}
if (this.error && this.measurements.length === 0) {
return html`
<style>${styleImports}</style>
<div>
<div class="widget-header">
<div>
<p class="widget-title">Current Measurements</p>
<p class="widget-subtitle">${deviceName}</p>
</div>
</div>
<div class="error-container">
<div class="alert alert-danger" role="alert">
<strong>Error:</strong> ${this.error}
</div>
</div>
</div>
`;
}
if (!this.error && this.measurements.length === 0) {
return html`
<style>${styleImports}</style>
<div>
<div class="widget-header">
<div>
<p class="widget-title">Current Measurements</p>
<p class="widget-subtitle">${deviceName}</p>
</div>
</div>
<div class="empty-state">
<h4>No Measurements Found</h4>
<p>No measurement data is available for "${deviceName}".</p>
</div>
</div>
`;
}
return html`
<style>${styleImports}</style>
<div>
<div class="widget-header">
<div>
<p class="widget-title">Current Measurements</p>
<p class="widget-subtitle">${deviceName}</p>
</div>
<div class="header-actions">
${this.refreshing
? html`<span class="refreshing-indicator" title="Refreshing..."></span>`
: ''}
${this.lastUpdated
? html`<span class="last-updated">Updated: ${this.lastUpdated}</span>`
: ''}
</div>
</div>
<div class="metrics-grid">
${this.measurements.map(m => {
const key = `${m.fragment}__${m.series}`;
const color = this._getColor(key);
return html`
<div class="metric-card" style="border-top: 3px solid ${color};">
<div class="metric-type-row">
<span class="metric-type" title="${this._formatLabel(m.fragment)}">
${this._formatLabel(m.fragment)}
</span>
</div>
<div class="metric-series" title="${m.series}">
Series: ${m.series}
</div>
<div class="metric-value-row">
<span class="metric-value">${this._formatValue(m.value)}</span>
${m.unit ? html`<span class="metric-unit">${m.unit}</span>` : ''}
</div>
<div class="metric-time" title="${this._formatTime(m.time)}">
${this._formatTime(m.time)}
</div>
${this._renderSparkline(key, color)}
</div>
`;
})}
</div>
</div>
`;
}
}
</details>

