背景
Adaptive Card が Updated されて、charts がいい感じになったと聞いたので試してみた
概要
グラフ表示ができるようになったので、以下をやってみた
- Azure の Resource Group 単位での日々のコスト通知を視覚化
以下も記述してあるので環境に合わせれば使えるはず
- Logic Apps の Code
通知イメージ
最新の Adaptive Card
制約と対処
- 凡例は、4点まで
- 今回4より多かったので、2つのチャートに分割するようにした。
- Javascript は、1024 文字まで
- 機能単位で分割&文字数減らすために変数名を短く
- 締め日が default の timeperiod で使えない場合
- timeframe を
custom定義する
- timeframe を
timeframe で、毎月 15 締めを行う場合
動的に生成する必要があったので、関数で生成
処理イメージ
- 16 以降
- 当月後半~翌月15
- 前月後半~当月15
custom timeframe
"timeframe": "Custom",
"timePeriod": {
"from": @{if(greaterOrEquals(dayOfMonth(utcNow()), 16), concat(formatDateTime(addDays(startOfMonth(utcNow()), 15), 'yyyy-MM-dd'), 'T00:00:00Z'), concat(formatDateTime(addDays(startOfMonth(subtractFromTime(utcNow(), 1, 'Month')), 15), 'yyyy-MM-dd'), 'T00:00:00Z'))},
"to": @{if(greaterOrEquals(dayOfMonth(utcNow()), 16), concat(formatDateTime(addDays(startOfMonth(subtractFromTime(utcNow(), -1, 'Month')), 14), 'yyyy-MM-dd'), 'T23:59:59Z'), concat(formatDateTime(addDays(startOfMonth(utcNow()), 14), 'yyyy-MM-dd'), 'T23:59:59Z'))}
},
設定
実行概要
- Cost management API に Query をなげてDaily収集
- 結果を Javascript で集計
- Teams へ投稿 with Adaptive Card
Cost 収集方法
以下で Query をなげて収集
Cost Management API
https://management.azure.com/subscriptions/{SubscriptionID}/providers/Microsoft.CostManagement/query?api-version=2025-03-01
以下は、Defaultだと、月初ー月末なので、締め日が違うときは修正必要
Daily でのCost 収集。
{
"type": "ActualCost",
"timeframe": "MonthToDate",
"dataset": {
"granularity": "Daily",
"aggregation": {
"totalCost": {
"name": "Cost",
"function": "Sum"
}
},
"grouping": [
{
"type": "Dimension",
"name": "ResourceGroupName"
}
],
"filter": {
"dimensions": {
"name": "ResourceGroupName",
"operator": "In",
"values": @{body('Select')}
}
}
}
}
Logic Apps の Code
- Teams 投稿のとこだけ削除して、CodeView を貼り付け
-
SubscriptionIDを変える - Teams の投稿のとこは、Connectorの問題があるので、
replyかpostのCard用Actionを用意して、以下、Adaptive Card を張り付ける
Adaptive Card
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5",
"msteams": {
"width": "full"
},
"body": [
{
"type": "TextBlock",
"text": "📊 Azure Cost Report - Multi Resource Groups",
"weight": "Bolder",
"size": "Large",
"spacing": "None"
},
{
"type": "TextBlock",
"text": "Period: @{body('Parse_JSON')?['period']} | Total: ¥@{body('Parse_JSON')?['grandTotal']} / Budget: ¥@{body('Parse_JSON')?['budget']}",
"wrap": true,
"spacing": "Small"
},
{
"type": "TextBlock",
"text": "💰 Overall Budget Status",
"weight": "Bolder",
"size": "Medium",
"spacing": "Medium",
"color": "Attention"
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "auto",
"items": [
{
"type": "Chart.Gauge",
"title": "@{body('Parse_JSON')?['usedPercent']}% Used",
"value": @{body('Parse_JSON')?['usedPercent']},
"segments": [
{
"legend": "Safe (75%)",
"size": 75,
"color": "good"
},
{
"legend": "Attention (75%-)",
"size": 20,
"color": "warning"
},
{
"legend": "High risk (95%-)",
"size": 5,
"color": "attention"
}
]
}
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "📋 Resource Group Summary",
"weight": "Bolder",
"size": "Medium",
"spacing": "None"
},
{
"type": "FactSet",
"facts": @{body('Parse_JSON')?['resourceGroupSummary'
]
}
}
]
}
]
},
{
"type": "ActionSet",
"actions": [
{
"type": "Action.ToggleVisibility",
"title": "📈 日次コスト詳細を表示",
"targetElements": [
"dailyBotCost",
"dailyOtherCost"
]
}
]
},
{
"type": "Container",
"id": "dailyBotCost",
"isVisible": false,
"items": [
{
"type": "TextBlock",
"text": "@{concat('📈 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'])}",
"weight": "Bolder",
"size": "Medium",
"spacing": "Large",
"separator": true
},
{
"type": "Chart.VerticalBar.Grouped",
"stacked": true,
"title": "@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'], ' (JPY)')}",
"yAxisTitle": "Cost (JPY)",
"colorSet": "diverging",
"data": @{body('Parse_JSON')?['dailyGroupedCharts'][0]['data']}
},
{
"type": "Container",
"id": "dailyOtherCost",
"isVisible": false,
"items": [
{
"type": "TextBlock",
"text": "@{concat('📊 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'])}",
"weight": "Bolder",
"size": "Medium",
"spacing": "Medium",
"separator": true
},
{
"type": "Chart.VerticalBar.Grouped",
"stacked": true,
"title": "@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'], ' (JPY)')}",
"yAxisTitle": "Cost (JPY)",
"data": @{body('Parse_JSON')?['dailyGroupedCharts'][1]['data']}
}
]
}
]
}
]
}
Code View での全体
{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"triggers": {
"Recurrence": {
"type": "Recurrence",
"recurrence": {
"interval": 1,
"frequency": "Day",
"timeZone": "Tokyo Standard Time",
"schedule": {
"hours": [
3
]
}
}
}
},
"actions": {
"HTTP": {
"type": "Http",
"inputs": {
"uri": "https://management.azure.com/subscriptions/{SubscriptionID}/providers/Microsoft.CostManagement/query?api-version=2025-03-01",
"method": "POST",
"body": {
"type": "ActualCost",
"timeframe": "MonthToDate",
"dataset": {
"granularity": "Daily",
"aggregation": {
"totalCost": {
"name": "Cost",
"function": "Sum"
}
},
"grouping": [
{
"type": "Dimension",
"name": "ResourceGroupName"
}
],
"filter": {
"dimensions": {
"name": "ResourceGroupName",
"operator": "In",
"values": "@body('Select')"
}
}
}
},
"authentication": {
"type": "ManagedServiceIdentity",
"audience": ""
}
},
"runAfter": {
"Compose_Budget": [
"Succeeded"
],
"Select": [
"Succeeded"
]
},
"runtimeConfiguration": {
"contentTransfer": {
"transferMode": "Chunked"
}
}
},
"step1": {
"type": "JavaScriptCode",
"inputs": {
"code": "function preprocessCost(apiResponse) {\r\n const rows = apiResponse.properties.rows;\r\n const rgData = {};\r\n const dates = new Set();\r\n\r\n rows.forEach(([cost, date, rgName]) => {\r\n const d = date.toString();\r\n const fd = `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}`;\r\n dates.add(fd);\r\n\r\n if (!rgData[rgName]) {\r\n rgData[rgName] = { daily: {}, total: 0 };\r\n }\r\n\r\n const c = parseFloat(cost.toFixed(2));\r\n rgData[rgName].daily[fd] = c;\r\n rgData[rgName].total += cost;\r\n });\r\n\r\n const sortedDates = Array.from(dates).sort();\r\n\r\n return {\r\n rgData,\r\n sortedDates,\r\n currency: rows[0][3],\r\n period: `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`\r\n };\r\n}\r\n\r\nreturn preprocessCost(workflowContext.actions.HTTP.outputs.body);\r\n"
},
"runAfter": {
"HTTP": [
"Succeeded"
]
}
},
"チャットやチャネルにカードを投稿する": {
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['teams']['connectionId']"
}
},
"method": "post",
"body": {
"recipient": "dd20711@melgit.com",
"messageBody": "{\n \"type\": \"AdaptiveCard\",\n \"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n \"version\": \"1.5\",\n \"msteams\": {\n \"width\": \"full\"\n },\n \"body\": [\n {\n \"type\": \"TextBlock\",\n \"text\": \"📊 Azure Cost Report - Multi Resource Groups\",\n \"weight\": \"Bolder\",\n \"size\": \"Large\",\n \"spacing\": \"None\"\n },\n {\n \"type\": \"TextBlock\",\n \"text\": \"Period: @{body('Parse_JSON')?['period']} | Total: ¥@{body('Parse_JSON')?['grandTotal']} / Budget: ¥@{body('Parse_JSON')?['budget']}\",\n \"wrap\": true,\n \"spacing\": \"Small\"\n },\n {\n \"type\": \"TextBlock\",\n \"text\": \"💰 Overall Budget Status\",\n \"weight\": \"Bolder\",\n \"size\": \"Medium\",\n \"spacing\": \"Medium\",\n \"color\": \"Attention\"\n },\n {\n \"type\": \"ColumnSet\",\n \"columns\": [\n {\n \"type\": \"Column\",\n \"width\": \"auto\",\n \"items\": [\n {\n \"type\": \"Chart.Gauge\",\n \"title\": \"@{body('Parse_JSON')?['usedPercent']}% Used\",\n \"value\": @{body('Parse_JSON')?['usedPercent']},\n \"segments\": [\n {\n \"legend\": \"Safe (75%)\",\n \"size\": 75,\n \"color\": \"good\"\n },\n {\n \"legend\": \"Attention (75%-)\",\n \"size\": 20,\n \"color\": \"warning\"\n },\n {\n \"legend\": \"High risk (95%-)\",\n \"size\": 5,\n \"color\": \"attention\"\n }\n ]\n }\n ]\n },\n {\n \"type\": \"Column\",\n \"width\": \"stretch\",\n \"items\": [\n {\n \"type\": \"TextBlock\",\n \"text\": \"📋 Resource Group Summary\",\n \"weight\": \"Bolder\",\n \"size\": \"Medium\",\n \"spacing\": \"None\"\n },\n {\n \"type\": \"FactSet\",\n \"facts\": @{body('Parse_JSON')?['resourceGroupSummary'\n ]\n }\n }\n ]\n }\n ]\n },\n {\n \"type\": \"ActionSet\",\n \"actions\": [\n {\n \"type\": \"Action.ToggleVisibility\",\n \"title\": \"📈 日次コスト詳細を表示\",\n \"targetElements\": [\n \"dailyBotCost\",\n \"dailyOtherCost\"\n ]\n }\n ]\n },\n {\n \"type\": \"Container\",\n \"id\": \"dailyBotCost\",\n \"isVisible\": false,\n \"items\": [\n {\n \"type\": \"TextBlock\",\n \"text\": \"@{concat('📈 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'])}\",\n \"weight\": \"Bolder\",\n \"size\": \"Medium\",\n \"spacing\": \"Large\",\n \"separator\": true\n },\n {\n \"type\": \"Chart.VerticalBar.Grouped\",\n \"stacked\": true,\n \"title\": \"@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'], ' (JPY)')}\",\n \"yAxisTitle\": \"Cost (JPY)\",\n \"colorSet\": \"diverging\",\n \"data\": @{body('Parse_JSON')?['dailyGroupedCharts'][0]['data']}\n },\n {\n \"type\": \"Container\",\n \"id\": \"dailyOtherCost\",\n \"isVisible\": false,\n \"items\": [\n {\n \"type\": \"TextBlock\",\n \"text\": \"@{concat('📊 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'])}\",\n \"weight\": \"Bolder\",\n \"size\": \"Medium\",\n \"spacing\": \"Medium\",\n \"separator\": true\n },\n {\n \"type\": \"Chart.VerticalBar.Grouped\",\n \"stacked\": true,\n \"title\": \"@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'], ' (JPY)')}\",\n \"yAxisTitle\": \"Cost (JPY)\",\n \"data\": @{body('Parse_JSON')?['dailyGroupedCharts'][1]['data']} \n }\n ]\n }\n ]\n}\n]\n}"
},
"path": "/v1.0/teams/conversation/adaptivecard/poster/Flow bot/location/@{encodeURIComponent('Chat with Flow bot')}"
},
"runAfter": {
"Parse_JSON": [
"SUCCEEDED"
]
}
},
"Parse_JSON": {
"type": "ParseJson",
"inputs": {
"content": "@body('step3')",
"schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"grandTotal": {
"type": "number"
},
"budget": {
"type": "number"
},
"currency": {
"type": "string"
},
"usedPercent": {
"type": "number"
},
"remainingPercent": {
"type": "number"
},
"dailyGroupedData": {
"type": "array",
"items": {
"type": "object",
"properties": {
"legend": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"type": "object",
"properties": {
"x": {
"type": "string"
},
"y": {
"type": "number"
}
}
}
}
}
}
},
"dailyGroupedCharts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"legend": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"type": "object",
"properties": {
"x": {
"type": "string"
},
"y": {
"type": "number"
}
}
}
}
}
}
}
}
}
},
"cumulativeStackedData": {
"type": "array",
"items": {
"type": "object",
"properties": {
"legend": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"type": "object",
"properties": {
"x": {
"type": "string"
},
"y": {
"type": "number"
}
}
}
}
}
}
},
"resourceGroupSummary": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
}
}
}
},
"runAfter": {
"step3": [
"Succeeded"
]
}
},
"step2": {
"type": "JavaScriptCode",
"inputs": {
"code": "function formatCostReport(p, budget) { const { rgData: r, sortedDates: d, currency: c, period } = p;\r\n const dailyGroupedData = Object.keys(r).map(g => ({\r\n legend: g, values: d.map(dt => ({ x: dt, y: r[g].daily[dt] || 0 }))\r\n }));\r\n const cumulativeStackedData = Object.keys(r).map(g => ({\r\n legend: g, values: [{ x: g, y: parseFloat(r[g].total.toFixed(2)) }]\r\n }));\r\n const resourceGroupSummary = Object.keys(r).map(g => ({\r\n title: g, value: `¥${parseFloat(r[g].total.toFixed(2))}`\r\n }));\r\n const grandTotal = parseFloat(\r\n Object.values(r).reduce((s, rg) => s + rg.total, 0).toFixed(2)\r\n );\r\n const used = parseFloat((grandTotal / budget * 100).toFixed(2));\r\n return {\r\n period, grandTotal, budget, currency: c,\r\n usedPercent: used,\r\n remainingPercent: parseFloat((100 - used).toFixed(2)),\r\n dailyGroupedData, cumulativeStackedData, resourceGroupSummary\r\n };\r\n}\r\nreturn formatCostReport(workflowContext.actions.step1.outputs.body, workflowContext.actions.Compose_Budget.outputs);\r\n"
},
"runAfter": {
"step1": [
"Succeeded"
]
}
},
"Initialize_variables": {
"type": "InitializeVariable",
"inputs": {
"variables": [
{
"name": "Budget",
"type": "integer",
"value": 7500
},
{
"name": "ResourceGroups",
"type": "array",
"value": [
{
"name": "ResourceGroupName1",
"title": "ボット用"
},
{
"name": "ResourceGroupName2",
"title": "その他"
}
]
}
]
},
"runAfter": {}
},
"Compose_Budget": {
"type": "Compose",
"inputs": "@variables('Budget')",
"runAfter": {
"Initialize_variables": [
"Succeeded"
]
}
},
"Select": {
"type": "Select",
"inputs": {
"from": "@variables('ResourceGroups')",
"select": "@item()['Name']"
},
"runAfter": {
"Initialize_variables": [
"Succeeded"
]
}
},
"step3": {
"type": "JavaScriptCode",
"inputs": {
"code": "function groupByTitle(r, m) {\r\n var idx = {}, order = [], i, j, k, e, t, name, data, item;\r\n\r\n for (i = 0; i < m.length; i++) {\r\n e = m[i];\r\n t = e.title;\r\n if (!t) { continue; }\r\n if (!idx[t]) {\r\n idx[t] = { title: t, names: [] };\r\n order.push(t);\r\n }\r\n idx[t].names.push(e.name);\r\n }\r\n\r\n var charts = [];\r\n for (i = 0; i < order.length; i++) {\r\n t = order[i];\r\n data = [];\r\n var names = idx[t].names;\r\n\r\n for (j = 0; j < names.length; j++) {\r\n name = names[j];\r\n for (k = 0; k < r.dailyGroupedData.length; k++) {\r\n item = r.dailyGroupedData[k];\r\n if (item.legend === name) {\r\n data.push(item);\r\n break;\r\n }\r\n }\r\n }\r\n\r\n charts.push({ title: t, data: data });\r\n }\r\n\r\n r.dailyGroupedCharts = charts;\r\n return r;\r\n}\r\n\r\nreturn groupByTitle(\r\n workflowContext.actions.Step2.outputs.body,\r\n workflowContext.actions.Compose_RGGroupMapping.outputs\r\n);\r\n"
},
"runAfter": {
"step2": [
"Succeeded"
],
"Compose_RGGroupMapping": [
"Succeeded"
]
}
},
"Compose_RGGroupMapping": {
"type": "Compose",
"inputs": "@variables('ResourceGroups')",
"runAfter": {
"Initialize_variables": [
"Succeeded"
]
}
}
},
"outputs": {},
"parameters": {
"$connections": {
"type": "Object",
"defaultValue": {}
}
}
},
"parameters": {
"$connections": {
"type": "Object",
"value": {
"teams": {
"id": "/subscriptions/{SubscriptionID}/providers/Microsoft.Web/locations/northcentralus/managedApis/teams",
"connectionId": "/subscriptions/{SubscriptionID}/resourceGroups/azure_cost_notifications/providers/Microsoft.Web/connections/teams",
"connectionName": "teams"
}
}
}
}
}


