[Observability] Exploratory View initial skeleton (#94426) (#96215)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Shahzad <shahzad.muhammad@elastic.co>
This commit is contained in:
Kibana Machine 2021-04-05 11:34:42 -04:00 committed by GitHub
parent 41d3fbbcce
commit 317e1e41d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 6057 additions and 54 deletions

View file

@ -83,6 +83,7 @@
"x-pack/plugins/uptime/server/lib/requests/helper.ts",
"x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx",
"x-pack/plugins/uptime/public/lib/helper/enzyme_helpers.tsx",
"x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx",
"x-pack/plugins/apm/server/utils/test_helpers.tsx",
"x-pack/plugins/apm/public/utils/testHelpers.tsx",

View file

@ -3,7 +3,7 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "observability"],
"optionalPlugins": ["licensing", "home", "usageCollection"],
"optionalPlugins": ["licensing", "home", "usageCollection","lens"],
"requiredPlugins": ["data"],
"ui": true,
"server": true,

View file

@ -0,0 +1,116 @@
<svg width="568" height="320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#25262E" d="M0 0h568v320H0z"/>
<g filter="url(#kibana_dashboard_dark__filter0_d)">
<rect x="24" y="172" width="248" height="124" rx="4" fill="#1D1E24"/>
</g>
<rect x="32" y="180" width="120" height="4" rx="2" fill="#A7AFBE"/>
<rect x="40" y="200" width="16" height="4" rx="2" fill="#535966"/>
<rect x="72" y="284" width="24" height="4" rx="2" fill="#535966"/>
<rect x="125" y="284" width="24" height="4" rx="2" fill="#535966"/>
<rect x="178" y="284" width="24" height="4" rx="2" fill="#535966"/>
<rect x="231" y="284" width="24" height="4" rx="2" fill="#535966"/>
<rect x="32" y="221" width="24" height="4" rx="2" fill="#535966"/>
<rect x="36" y="242" width="20" height="4" rx="2" fill="#535966"/>
<rect x="40" y="263" width="16" height="4" rx="2" transform="rotate(-.17 40 263)" fill="#535966"/>
<path d="M64.5 192.5v9.5m199 73.5H243M64.5 202h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 265h-4M84 279.5v-4m0 0h53m0 0v4m0-4h53m0 0v4m0-4h53m0 0v4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="67" y="194" width="181" height="16" rx="1" fill="#54B399"/>
<rect x="67" y="236" width="59" height="16" rx="1" fill="#D36086"/>
<rect x="67" y="215" width="120" height="16" rx="1" fill="#6092C0"/>
<rect x="67" y="257" width="30" height="16" rx="1" fill="#9170B8"/>
<g filter="url(#kibana_dashboard_dark__filter1_d)">
<rect x="24" y="24" width="520" height="124" rx="4" fill="#1D1E24"/>
</g>
<rect x="32" y="32" width="101" height="4" rx="2" fill="#A7AFBE"/>
<rect x="38" y="52" width="18" height="4" rx="2" fill="#535966"/>
<rect x="32" y="73" width="24" height="4" rx="2" fill="#535966"/>
<rect x="40" y="94" width="16" height="4" rx="2" fill="#535966"/>
<rect x="36" y="115" width="20" height="4" rx="2" fill="#535966"/>
<rect x="72" y="136" width="24" height="4" rx="2" fill="#535966"/>
<rect x="216" y="136" width="24" height="4" rx="2" fill="#535966"/>
<rect x="360" y="136" width="24" height="4" rx="2" fill="#535966"/>
<rect x="504" y="136" width="24" height="4" rx="2" fill="#535966"/>
<path opacity=".1" d="M80.177 118L65 110v17h470.5V90.5l-15.177 12.692-15.178 1.346-15.177-8.846-15.178 17.885-15.177.961-15.178-5.384-15.177-.769-15.177 5.384-15.178-5.846-15.177-40.615-15.178 18.077-15.177-38.462L338.194 104l-15.178-16.308-15.177 12.923-15.178-9.538-15.177 5.615-15.178-24.384L247.129 71l-15.177-15.615-15.178 49.077-15.177 4.615-15.178-22.154-15.177-5.385L156.065 50l-15.178 37.923-15.177 6.923-15.178 19.692-15.177-4.692L80.177 118z" fill="#54B399"/>
<path d="M65 110l15.177 8 15.178-8.154 15.177 4.692 15.178-19.692 15.177-6.923L156.065 50l15.177 31.538 15.177 5.385 15.178 22.154 15.177-4.615 15.178-49.077L247.129 71l15.177 1.308 15.178 24.384 15.177-5.615 15.178 9.538 15.177-12.923L338.194 104l15.177-57.077 15.177 38.462 15.178-18.077 15.177 40.615 15.178 5.846 15.177-5.384 15.177.769 15.178 5.384 15.177-.961 15.178-17.885 15.177 8.846 15.178-1.346L535.5 90.5" stroke="#54B399" stroke-linecap="round" stroke-linejoin="round"/>
<circle opacity=".1" cx="100" cy="63" r="8" fill="#6092C0"/>
<circle opacity=".1" cx="232" cy="55" r="8" fill="#6092C0"/>
<circle opacity=".1" cx="323" cy="88" r="8" fill="#6092C0"/>
<circle opacity=".1" cx="353" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="523" cy="47" r="8" fill="#6092C0"/>
<circle opacity=".1" cx="106" cy="68" r="4" fill="#6092C0"/>
<circle opacity=".1" cx="156" cy="50" r="4" fill="#6092C0"/>
<circle opacity=".1" cx="240" cy="55" r="4" fill="#6092C0"/>
<circle opacity=".1" cx="262" cy="72" r="4" fill="#6092C0"/>
<circle opacity=".1" cx="345" cy="48" r="4" fill="#6092C0"/>
<circle opacity=".1" cx="484" cy="92" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="490" cy="96" r="4" fill="#6092C0"/>
<circle cx="100" cy="63" r="7.5" stroke="#6092C0"/>
<circle cx="232" cy="55" r="7.5" stroke="#6092C0"/>
<circle cx="323" cy="88" r="7.5" stroke="#6092C0"/>
<circle cx="353" cy="47" r="7.5" stroke="#6092C0"/>
<circle cx="523" cy="47" r="7.5" stroke="#6092C0"/>
<circle cx="106" cy="68" r="3.5" stroke="#6092C0"/>
<circle cx="156" cy="50" r="3.5" stroke="#6092C0"/>
<circle cx="240" cy="55" r="3.5" stroke="#6092C0"/>
<circle cx="262" cy="72" r="3.5" stroke="#6092C0"/>
<circle cx="345" cy="48" r="3.5" stroke="#6092C0"/>
<circle cx="484" cy="92" r="3.5" stroke="#6092C0"/>
<circle cx="490" cy="96" r="3.5" stroke="#6092C0"/>
<path d="M64.5 44.5V54m471 73.5H516M64.5 54h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 117h-4M84 131.5v-4m0 0h144m0 0v4m0-4h144m0 0v4m0-4h144m0 0v4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#kibana_dashboard_dark__filter2_d)">
<rect x="296" y="172" width="248" height="124" rx="4" fill="#1D1E24"/>
</g>
<rect x="304" y="180" width="80" height="4" rx="2" fill="#A7AFBE"/>
<path d="M448.284 268.284a39.997 39.997 0 00-10.054-63.888 39.997 39.997 0 00-24.383-3.92l2.461 15.81a24 24 0 0120.663 40.685l11.313 11.313z" fill="#54B399"/>
<mask id="a" maskUnits="userSpaceOnUse" x="385" y="250" width="72" height="31" fill="#000">
<path fill="#fff" d="M385 250h72v31h-72z"/>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z"/>
</mask>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" fill="#6092C0"/>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" stroke="#1D1E24" stroke-width="2" mask="url(#a)"/>
<mask id="b" maskUnits="userSpaceOnUse" x="379" y="213" width="24" height="54" fill="#000">
<path fill="#fff" d="M379 213h24v54h-24z"/>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z"/>
</mask>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" fill="#D36086"/>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" stroke="#1D1E24" stroke-width="2" mask="url(#b)"/>
<mask id="c" maskUnits="userSpaceOnUse" x="384" y="199" width="37" height="30" fill="#000">
<path fill="#fff" d="M384 199h37v30h-37z"/>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z"/>
</mask>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" fill="#9170B8"/>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" stroke="#1D1E24" stroke-width="2" mask="url(#c)"/>
<rect x="367" y="192" width="22" height="4" rx="2" fill="#535966"/>
<rect x="345" y="250" width="16" height="4" rx="2" fill="#535966"/>
<rect x="441" y="284" width="24" height="4" rx="2" fill="#535966"/>
<rect x="471" y="210" width="24" height="4" rx="2" fill="#535966"/>
<path d="M393.5 194h4l4 8.5m65 9.5h-4l-7 4.5m-26.5 64l3.5 5.5h4m-58-41l-9 7h-4" stroke="#343741" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="kibana_dashboard_dark__filter0_d" x="8" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="kibana_dashboard_dark__filter1_d" x="8" y="12" width="552" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="kibana_dashboard_dark__filter2_d" x="280" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -0,0 +1,116 @@
<svg width="568" height="320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#F5F7FA" d="M0 0h568v320H0z"/>
<g filter="url(#kibana_dashboard_light__filter0_d)">
<rect x="24" y="172" width="248" height="124" rx="4" fill="#fff"/>
</g>
<rect x="32" y="180" width="120" height="4" rx="2" fill="#6A717D"/>
<rect x="40" y="200" width="16" height="4" rx="2" fill="#98A2B3"/>
<rect x="72" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="125" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="178" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="231" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="32" y="221" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="36" y="242" width="20" height="4" rx="2" fill="#98A2B3"/>
<rect x="40" y="263" width="16" height="4" rx="2" transform="rotate(-.17 40 263)" fill="#98A2B3"/>
<path d="M64.5 192.5v9.5m199 73.5H243M64.5 202h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 265h-4M84 279.5v-4m0 0h53m0 0v4m0-4h53m0 0v4m0-4h53m0 0v4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="67" y="194" width="181" height="16" rx="1" fill="#54B399"/>
<rect x="67" y="236" width="59" height="16" rx="1" fill="#D36086"/>
<rect x="67" y="215" width="120" height="16" rx="1" fill="#6092C0"/>
<rect x="67" y="257" width="30" height="16" rx="1" fill="#9170B8"/>
<g filter="url(#kibana_dashboard_light__filter1_d)">
<rect x="24" y="24" width="520" height="124" rx="4" fill="#fff"/>
</g>
<rect x="32" y="32" width="101" height="4" rx="2" fill="#6A717D"/>
<rect x="38" y="52" width="18" height="4" rx="2" fill="#98A2B3"/>
<rect x="32" y="73" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="40" y="94" width="16" height="4" rx="2" fill="#98A2B3"/>
<rect x="36" y="115" width="20" height="4" rx="2" fill="#98A2B3"/>
<rect x="72" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="216" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="360" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="504" y="136" width="24" height="4" rx="2" fill="#98A2B3"/>
<path opacity=".1" d="M80.177 118L65 110v17h470.5V90.5l-15.177 12.692-15.178 1.346-15.177-8.846-15.178 17.885-15.177.961-15.178-5.384-15.177-.769-15.177 5.384-15.178-5.846-15.177-40.615-15.178 18.077-15.177-38.462L338.194 104l-15.178-16.308-15.177 12.923-15.178-9.538-15.177 5.615-15.178-24.384L247.129 71l-15.177-15.615-15.178 49.077-15.177 4.615-15.178-22.154-15.177-5.385L156.065 50l-15.178 37.923-15.177 6.923-15.178 19.692-15.177-4.692L80.177 118z" fill="#54B399"/>
<path d="M65 110l15.177 8 15.178-8.154 15.177 4.692 15.178-19.692 15.177-6.923L156.065 50l15.177 31.538 15.177 5.385 15.178 22.154 15.177-4.615 15.178-49.077L247.129 71l15.177 1.308 15.178 24.384 15.177-5.615 15.178 9.538 15.177-12.923L338.194 104l15.177-57.077 15.177 38.462 15.178-18.077 15.177 40.615 15.178 5.846 15.177-5.384 15.177.769 15.178 5.384 15.177-.961 15.178-17.885 15.177 8.846 15.178-1.346L535.5 90.5" stroke="#54B399" stroke-linecap="round" stroke-linejoin="round"/>
<circle opacity=".1" cx="100" cy="63" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="232" cy="55" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="323" cy="88" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="353" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="523" cy="47" r="7.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="106" cy="68" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="156" cy="50" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="240" cy="55" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="262" cy="72" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="345" cy="48" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="484" cy="92" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle opacity=".1" cx="490" cy="96" r="3.5" fill="#6092C0" stroke="#6092C0"/>
<circle cx="100" cy="63" r="7.5" stroke="#6092C0"/>
<circle cx="232" cy="55" r="7.5" stroke="#6092C0"/>
<circle cx="323" cy="88" r="7.5" stroke="#6092C0"/>
<circle cx="353" cy="47" r="7.5" stroke="#6092C0"/>
<circle cx="523" cy="47" r="7.5" stroke="#6092C0"/>
<circle cx="106" cy="68" r="3.5" stroke="#6092C0"/>
<circle cx="156" cy="50" r="3.5" stroke="#6092C0"/>
<circle cx="240" cy="55" r="3.5" stroke="#6092C0"/>
<circle cx="262" cy="72" r="3.5" stroke="#6092C0"/>
<circle cx="345" cy="48" r="3.5" stroke="#6092C0"/>
<circle cx="484" cy="92" r="3.5" stroke="#6092C0"/>
<circle cx="490" cy="96" r="3.5" stroke="#6092C0"/>
<path d="M64.5 44.5V54m471 73.5H516M64.5 54h-4m4 0v21m0 0h-4m4 0v21m0 0h-4m4 0v21m0 0v10.5H84M64.5 117h-4M84 131.5v-4m0 0h144m0 0v4m0-4h144m0 0v4m0-4h144m0 0v4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
<g filter="url(#kibana_dashboard_light__filter2_d)">
<rect x="296" y="172" width="248" height="124" rx="4" fill="#fff"/>
</g>
<rect x="304" y="180" width="80" height="4" rx="2" fill="#6A717D"/>
<path d="M448.284 268.284a39.997 39.997 0 00-10.054-63.888 39.997 39.997 0 00-24.383-3.92l2.461 15.81a24 24 0 0120.663 40.685l11.313 11.313z" fill="#54B399"/>
<mask id="a" maskUnits="userSpaceOnUse" x="385" y="250" width="72" height="31" fill="#000">
<path fill="#fff" d="M385 250h72v31h-72z"/>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z"/>
</mask>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" fill="#6092C0"/>
<path d="M386.416 261.728a39.996 39.996 0 0034.591 18.259 39.994 39.994 0 0033.628-19.976l-13.854-8.005a24 24 0 01-40.932 1.031l-13.433 8.691z" stroke="#fff" stroke-width="2" mask="url(#a)"/>
<mask id="b" maskUnits="userSpaceOnUse" x="379" y="213" width="24" height="54" fill="#000">
<path fill="#fff" d="M379 213h24v54h-24z"/>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z"/>
</mask>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" fill="#D36086"/>
<path d="M388.645 215.163a40 40 0 00.54 50.341l12.326-10.202a24 24 0 01-.324-30.204l-12.542-9.935z" stroke="#fff" stroke-width="2" mask="url(#b)"/>
<mask id="c" maskUnits="userSpaceOnUse" x="384" y="199" width="37" height="30" fill="#000">
<path fill="#fff" d="M384 199h37v30h-37z"/>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z"/>
</mask>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" fill="#9170B8"/>
<path d="M420 200a40.005 40.005 0 00-33.929 18.814l13.572 8.474A24.004 24.004 0 01420 216v-16z" stroke="#fff" stroke-width="2" mask="url(#c)"/>
<rect x="367" y="192" width="22" height="4" rx="2" fill="#98A2B3"/>
<rect x="345" y="250" width="16" height="4" rx="2" fill="#98A2B3"/>
<rect x="441" y="284" width="24" height="4" rx="2" fill="#98A2B3"/>
<rect x="471" y="210" width="24" height="4" rx="2" fill="#98A2B3"/>
<path d="M393.5 194h4l4 8.5m65 9.5h-4l-7 4.5m-26.5 64l3.5 5.5h4m-58-41l-9 7h-4" stroke="#D3DAE6" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="kibana_dashboard_light__filter0_d" x="8" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="kibana_dashboard_light__filter1_d" x="8" y="12" width="552" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="kibana_dashboard_light__filter2_d" x="280" y="160" width="280" height="156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -59,13 +59,13 @@ export function Header({ color, datePicker = null, restrictWidth }: Props) {
</HeaderMenuPortal>
<Wrapper restrictWidth={restrictWidth}>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiIcon type="logoObservability" size="xxl" data-test-subj="observability-logo" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiTitle>
<h1>
{i18n.translate('xpack.observability.home.title', {

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiImage } from '@elastic/eui';
import styled from 'styled-components';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
export function EmptyView() {
const {
services: { http },
} = useKibana();
return (
<Wrapper>
<EuiImage
alt="Visulization"
url={http!.basePath.prepend(`/plugins/observability/assets/kibana_dashboard_light.svg`)}
/>
</Wrapper>
);
}
const Wrapper = styled.div`
text-align: center;
opacity: 0.4;
height: 550px;
`;

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { mockIndexPattern, render } from '../rtl_helpers';
import { buildFilterLabel, FilterLabel } from './filter_label';
import * as useSeriesHook from '../hooks/use_series_filters';
describe('FilterLabel', function () {
const invertFilter = jest.fn();
jest.spyOn(useSeriesHook, 'useSeriesFilters').mockReturnValue({
invertFilter,
} as any);
it('should render properly', async function () {
render(
<FilterLabel
field={'service.name'}
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={'kpi-trends'}
removeFilter={jest.fn()}
/>
);
await waitFor(() => {
screen.getByText('elastic-co');
screen.getByText(/web application:/i);
screen.getByTitle('Delete Web Application: elastic-co');
screen.getByRole('button', {
name: /delete web application: elastic-co/i,
});
});
});
it.skip('should delete filter', async function () {
const removeFilter = jest.fn();
render(
<FilterLabel
field={'service.name'}
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={'kpi-trends'}
removeFilter={removeFilter}
/>
);
await waitFor(() => {
fireEvent.click(screen.getByLabelText('Filter actions'));
});
fireEvent.click(screen.getByTestId('deleteFilter'));
expect(removeFilter).toHaveBeenCalledTimes(1);
expect(removeFilter).toHaveBeenCalledWith('service.name', 'elastic-co', false);
});
it.skip('should invert filter', async function () {
const removeFilter = jest.fn();
render(
<FilterLabel
field={'service.name'}
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={'kpi-trends'}
removeFilter={removeFilter}
/>
);
await waitFor(() => {
fireEvent.click(screen.getByLabelText('Filter actions'));
});
fireEvent.click(screen.getByTestId('negateFilter'));
expect(invertFilter).toHaveBeenCalledTimes(1);
expect(invertFilter).toHaveBeenCalledWith({
field: 'service.name',
negate: false,
value: 'elastic-co',
});
});
it('should display invert filter', async function () {
render(
<FilterLabel
field={'service.name'}
value={'elastic-co'}
label={'Web Application'}
negate={true}
seriesId={'kpi-trends'}
removeFilter={jest.fn()}
/>
);
await waitFor(() => {
screen.getByText('elastic-co');
screen.getByText(/web application:/i);
screen.getByTitle('Delete NOT Web Application: elastic-co');
screen.getByRole('button', {
name: /delete not web application: elastic-co/i,
});
});
});
it('should build filter meta', function () {
expect(
buildFilterLabel({
field: 'user_agent.name',
label: 'Browser family',
indexPattern: mockIndexPattern,
value: 'Firefox',
negate: false,
})
).toEqual({
meta: {
alias: null,
disabled: false,
index: 'apm-*',
key: 'Browser family',
negate: false,
type: 'phrase',
value: 'Firefox',
},
query: {
match_phrase: {
'user_agent.name': 'Firefox',
},
},
});
});
});

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { injectI18n } from '@kbn/i18n/react';
import { esFilters, Filter, IndexPattern } from '../../../../../../../../src/plugins/data/public';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { useSeriesFilters } from '../hooks/use_series_filters';
interface Props {
field: string;
label: string;
value: string;
seriesId: string;
negate: boolean;
definitionFilter?: boolean;
removeFilter: (field: string, value: string, notVal: boolean) => void;
}
export function buildFilterLabel({
field,
value,
label,
indexPattern,
negate,
}: {
label: string;
value: string;
negate: boolean;
field: string;
indexPattern: IndexPattern;
}) {
const indexField = indexPattern.getFieldByName(field)!;
const filter = esFilters.buildPhraseFilter(indexField, value, indexPattern);
filter.meta.value = value;
filter.meta.key = label;
filter.meta.alias = null;
filter.meta.negate = negate;
filter.meta.disabled = false;
filter.meta.type = 'phrase';
return filter;
}
export function FilterLabel({
label,
seriesId,
field,
value,
negate,
removeFilter,
definitionFilter,
}: Props) {
const FilterItem = injectI18n(esFilters.FilterItem);
const { indexPattern } = useIndexPatternContext();
const filter = buildFilterLabel({ field, value, label, indexPattern, negate });
const { invertFilter } = useSeriesFilters({ seriesId });
const {
services: { uiSettings },
} = useKibana();
return indexPattern ? (
<FilterItem
indexPatterns={[indexPattern]}
id={`${field}-${value}-${negate}`}
filter={filter}
onRemove={() => {
removeFilter(field, value, false);
}}
onUpdate={(filterN: Filter) => {
if (definitionFilter) {
// FIXME handle this use case
} else if (filterN.meta.negate !== negate) {
invertFilter({ field, value, negate });
}
}}
uiSettings={uiSettings!}
hiddenPanelOptions={['pinFilter', 'editFilter', 'disableFilter']}
/>
) : null;
}

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AppDataType, ReportViewTypeId } from '../types';
import {
CLS_FIELD,
FCP_FIELD,
FID_FIELD,
LCP_FIELD,
TBT_FIELD,
} from './data/elasticsearch_fieldnames';
export const FieldLabels: Record<string, string> = {
'user_agent.name': 'Browser family',
'user_agent.version': 'Browser version',
'user_agent.os.name': 'Operating system',
'client.geo.country_name': 'Location',
'user_agent.device.name': 'Device',
'observer.geo.name': 'Observer location',
'service.name': 'Service Name',
'service.environment': 'Environment',
[LCP_FIELD]: 'Largest contentful paint',
[FCP_FIELD]: 'First contentful paint',
[TBT_FIELD]: 'Total blocking time',
[FID_FIELD]: 'First input delay',
[CLS_FIELD]: 'Cumulative layout shift',
'monitor.id': 'Monitor Id',
'monitor.status': 'Monitor Status',
'agent.hostname': 'Agent host',
'host.hostname': 'Host name',
'monitor.name': 'Monitor name',
'monitor.type': 'Monitor Type',
'url.port': 'Port',
tags: 'Tags',
// custom
'performance.metric': 'Metric',
'Business.KPI': 'KPI',
};
export const DataViewLabels: Record<ReportViewTypeId, string> = {
pld: 'Performance Distribution',
upd: 'Uptime monitor duration',
upp: 'Uptime pings',
svl: 'APM Service latency',
kpi: 'KPI over time',
tpt: 'APM Service throughput',
cpu: 'System CPU Usage',
logs: 'Logs Frequency',
mem: 'System Memory Usage',
nwk: 'Network Activity',
};
export const ReportToDataTypeMap: Record<ReportViewTypeId, AppDataType> = {
upd: 'synthetics',
upp: 'synthetics',
tpt: 'apm',
svl: 'apm',
kpi: 'rum',
pld: 'rum',
nwk: 'metrics',
mem: 'metrics',
logs: 'logs',
cpu: 'metrics',
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';
interface Props {
seriesId: string;
}
export function getCPUUsageLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'cpu-usage',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.cpu.user.pct',
label: 'CPU Usage %',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'agent.hostname',
required: true,
},
],
};
}

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const CLOUD = 'cloud';
export const CLOUD_AVAILABILITY_ZONE = 'cloud.availability_zone';
export const CLOUD_PROVIDER = 'cloud.provider';
export const CLOUD_REGION = 'cloud.region';
export const CLOUD_MACHINE_TYPE = 'cloud.machine.type';
export const SERVICE = 'service';
export const SERVICE_NAME = 'service.name';
export const SERVICE_ENVIRONMENT = 'service.environment';
export const SERVICE_FRAMEWORK_NAME = 'service.framework.name';
export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version';
export const SERVICE_LANGUAGE_NAME = 'service.language.name';
export const SERVICE_LANGUAGE_VERSION = 'service.language.version';
export const SERVICE_RUNTIME_NAME = 'service.runtime.name';
export const SERVICE_RUNTIME_VERSION = 'service.runtime.version';
export const SERVICE_NODE_NAME = 'service.node.name';
export const SERVICE_VERSION = 'service.version';
export const AGENT = 'agent';
export const AGENT_NAME = 'agent.name';
export const AGENT_VERSION = 'agent.version';
export const URL_FULL = 'url.full';
export const HTTP_REQUEST_METHOD = 'http.request.method';
export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code';
export const USER_ID = 'user.id';
export const USER_AGENT_ORIGINAL = 'user_agent.original';
export const USER_AGENT_NAME = 'user_agent.name';
export const USER_AGENT_VERSION = 'user_agent.version';
export const DESTINATION_ADDRESS = 'destination.address';
export const OBSERVER_HOSTNAME = 'observer.hostname';
export const OBSERVER_VERSION_MAJOR = 'observer.version_major';
export const OBSERVER_LISTENING = 'observer.listening';
export const PROCESSOR_EVENT = 'processor.event';
export const TRANSACTION_DURATION = 'transaction.duration.us';
export const TRANSACTION_DURATION_HISTOGRAM = 'transaction.duration.histogram';
export const TRANSACTION_TYPE = 'transaction.type';
export const TRANSACTION_RESULT = 'transaction.result';
export const TRANSACTION_NAME = 'transaction.name';
export const TRANSACTION_ID = 'transaction.id';
export const TRANSACTION_SAMPLED = 'transaction.sampled';
export const TRANSACTION_BREAKDOWN_COUNT = 'transaction.breakdown.count';
export const TRANSACTION_PAGE_URL = 'transaction.page.url';
// for transaction metrics
export const TRANSACTION_ROOT = 'transaction.root';
export const EVENT_OUTCOME = 'event.outcome';
export const TRACE_ID = 'trace.id';
export const SPAN_DURATION = 'span.duration.us';
export const SPAN_TYPE = 'span.type';
export const SPAN_SUBTYPE = 'span.subtype';
export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us';
export const SPAN_ACTION = 'span.action';
export const SPAN_NAME = 'span.name';
export const SPAN_ID = 'span.id';
export const SPAN_DESTINATION_SERVICE_RESOURCE = 'span.destination.service.resource';
export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT =
'span.destination.service.response_time.count';
export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM =
'span.destination.service.response_time.sum.us';
// Parent ID for a transaction or span
export const PARENT_ID = 'parent.id';
export const ERROR_GROUP_ID = 'error.grouping_key';
export const ERROR_CULPRIT = 'error.culprit';
export const ERROR_LOG_LEVEL = 'error.log.level';
export const ERROR_LOG_MESSAGE = 'error.log.message';
export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used in es queries, since error.exception is now an array
export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array
export const ERROR_EXC_TYPE = 'error.exception.type';
export const ERROR_PAGE_URL = 'error.page.url';
// METRICS
export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free';
export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total';
export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct';
export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct';
export const METRIC_CGROUP_MEMORY_LIMIT_BYTES = 'system.process.cgroup.memory.mem.limit.bytes';
export const METRIC_CGROUP_MEMORY_USAGE_BYTES = 'system.process.cgroup.memory.mem.usage.bytes';
export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max';
export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed';
export const METRIC_JAVA_HEAP_MEMORY_USED = 'jvm.memory.heap.used';
export const METRIC_JAVA_NON_HEAP_MEMORY_MAX = 'jvm.memory.non_heap.max';
export const METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED = 'jvm.memory.non_heap.committed';
export const METRIC_JAVA_NON_HEAP_MEMORY_USED = 'jvm.memory.non_heap.used';
export const METRIC_JAVA_THREAD_COUNT = 'jvm.thread.count';
export const METRIC_JAVA_GC_COUNT = 'jvm.gc.count';
export const METRIC_JAVA_GC_TIME = 'jvm.gc.time';
export const LABEL_NAME = 'labels.name';
export const HOST = 'host';
export const HOST_NAME = 'host.hostname';
export const HOST_OS_PLATFORM = 'host.os.platform';
export const CONTAINER_ID = 'container.id';
export const KUBERNETES = 'kubernetes';
export const POD_NAME = 'kubernetes.pod.name';
export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code';
export const CLIENT_GEO_COUNTRY_NAME = 'client.geo.country_name';
// RUM Labels
export const TRANSACTION_URL = 'url.full';
export const CLIENT_GEO = 'client.geo';
export const USER_AGENT_DEVICE = 'user_agent.device.name';
export const USER_AGENT_OS = 'user_agent.os.name';
export const TRANSACTION_TIME_TO_FIRST_BYTE = 'transaction.marks.agent.timeToFirstByte';
export const TRANSACTION_DOM_INTERACTIVE = 'transaction.marks.agent.domInteractive';
export const FCP_FIELD = 'transaction.marks.agent.firstContentfulPaint';
export const LCP_FIELD = 'transaction.marks.agent.largestContentfulPaint';
export const TBT_FIELD = 'transaction.experience.tbt';
export const FID_FIELD = 'transaction.experience.fid';
export const CLS_FIELD = 'transaction.experience.cls';
export const PROFILE_ID = 'profile.id';
export const PROFILE_DURATION = 'profile.duration';
export const PROFILE_TOP_ID = 'profile.top.id';
export const PROFILE_STACK = 'profile.stack';
export const PROFILE_SAMPLES_COUNT = 'profile.samples.count';
export const PROFILE_CPU_NS = 'profile.cpu.ns';
export const PROFILE_WALL_US = 'profile.wall.us';
export const PROFILE_ALLOC_OBJECTS = 'profile.alloc_objects.count';
export const PROFILE_ALLOC_SPACE = 'profile.alloc_space.bytes';
export const PROFILE_INUSE_OBJECTS = 'profile.inuse_objects.count';
export const PROFILE_INUSE_SPACE = 'profile.inuse_space.bytes';

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const sampleAttribute = {
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{ id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer1', type: 'index-pattern' },
],
state: {
datasourceStates: {
indexpattern: {
layers: {
layer1: {
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': {
sourceField: 'transaction.duration.us',
label: 'Page load time',
dataType: 'number',
operationType: 'range',
isBucketed: true,
scale: 'interval',
params: {
type: 'histogram',
ranges: [{ from: 0, to: 1000, label: '' }],
maxBars: 'auto',
},
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
},
},
},
},
visualization: {
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
curveType: 'CURVE_MONOTONE_X',
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
seriesType: 'line',
yConfig: [{ forAccessor: 'y-axis-column', color: 'green' }],
xAccessor: 'x-axis-column',
},
],
},
query: { query: '', language: 'kuery' },
filters: [
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
],
},
};

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ReportViewTypes } from '../types';
import { getPerformanceDistLensConfig } from './performance_dist_config';
import { getMonitorDurationConfig } from './monitor_duration_config';
import { getServiceLatencyLensConfig } from './service_latency_config';
import { getMonitorPingsConfig } from './monitor_pings_config';
import { getServiceThroughputLensConfig } from './service_throughput_config';
import { getKPITrendsLensConfig } from './kpi_trends_config';
import { getCPUUsageLensConfig } from './cpu_usage_config';
import { getMemoryUsageLensConfig } from './memory_usage_config';
import { getNetworkActivityLensConfig } from './network_activity_config';
import { getLogsFrequencyLensConfig } from './logs_frequency_config';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';
interface Props {
reportType: keyof typeof ReportViewTypes;
seriesId: string;
indexPattern: IIndexPattern;
}
export const getDefaultConfigs = ({ reportType, seriesId, indexPattern }: Props) => {
switch (ReportViewTypes[reportType]) {
case 'page-load-dist':
return getPerformanceDistLensConfig({ seriesId, indexPattern });
case 'kpi-trends':
return getKPITrendsLensConfig({ seriesId, indexPattern });
case 'uptime-duration':
return getMonitorDurationConfig({ seriesId });
case 'uptime-pings':
return getMonitorPingsConfig({ seriesId });
case 'service-latency':
return getServiceLatencyLensConfig({ seriesId, indexPattern });
case 'service-throughput':
return getServiceThroughputLensConfig({ seriesId, indexPattern });
case 'cpu-usage':
return getCPUUsageLensConfig({ seriesId });
case 'memory-usage':
return getMemoryUsageLensConfig({ seriesId });
case 'network-activity':
return getNetworkActivityLensConfig({ seriesId });
case 'logs-frequency':
return getLogsFrequencyLensConfig({ seriesId });
default:
return getKPITrendsLensConfig({ seriesId, indexPattern });
}
};

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import {
CLIENT_GEO_COUNTRY_NAME,
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_TYPE,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
USER_AGENT_VERSION,
} from './data/elasticsearch_fieldnames';
export function getKPITrendsLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId,
defaultSeriesType: 'bar_stacked',
reportType: 'kpi-trends',
seriesTypes: ['bar', 'bar_stacked'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
label: 'Page views',
},
hasMetricType: false,
defaultFilters: [
USER_AGENT_OS,
CLIENT_GEO_COUNTRY_NAME,
USER_AGENT_DEVICE,
{
field: USER_AGENT_NAME,
nested: USER_AGENT_VERSION,
},
],
breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE],
filters: [
buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern),
buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern),
],
labels: { ...FieldLabels, SERVICE_NAME: 'Web Application' },
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
},
{
field: 'Business.KPI',
custom: true,
defaultValue: 'Records',
options: [
{
field: 'Records',
label: 'Page views',
},
],
},
],
};
}

View file

@ -0,0 +1,387 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LensAttributes } from './lens_attributes';
import { mockIndexPattern } from '../rtl_helpers';
import { getDefaultConfigs } from './default_configs';
import { sampleAttribute } from './data/sample_attribute';
import { LCP_FIELD, SERVICE_NAME } from './data/elasticsearch_fieldnames';
import { USER_AGENT_NAME } from './data/elasticsearch_fieldnames';
describe('Lens Attribute', () => {
const reportViewConfig = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: 'series-id',
});
let lnsAttr: LensAttributes;
beforeEach(() => {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {});
});
it('should return expected json', function () {
expect(lnsAttr.getJSON()).toEqual(sampleAttribute);
});
it('should return main y axis', function () {
expect(lnsAttr.getMainYAxis()).toEqual({
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
});
});
it('should return expected field type', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('transaction.type'))).toEqual(
JSON.stringify({
count: 0,
name: 'transaction.type',
type: 'string',
esTypes: ['keyword'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});
it('should return expected field type for custom field with default value', function () {
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
JSON.stringify({
count: 0,
name: 'transaction.duration.us',
type: 'number',
esTypes: ['long'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});
it('should return expected field type for custom field with passed value', function () {
lnsAttr = new LensAttributes(mockIndexPattern, reportViewConfig, 'line', [], 'count', {
'performance.metric': LCP_FIELD,
});
expect(JSON.stringify(lnsAttr.getFieldMeta('performance.metric'))).toEqual(
JSON.stringify({
count: 0,
name: LCP_FIELD,
type: 'number',
esTypes: ['scaled_float'],
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
})
);
});
it('should return expected number column', function () {
expect(lnsAttr.getNumberColumn('transaction.duration.us')).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
});
});
it('should return expected date histogram column', function () {
expect(lnsAttr.getDateHistogramColumn('@timestamp')).toEqual({
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: {
interval: 'auto',
},
scale: 'interval',
sourceField: '@timestamp',
});
});
it('should return main x axis', function () {
expect(lnsAttr.getXAxis()).toEqual({
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
});
});
it('should return first layer', function () {
expect(lnsAttr.getLayer()).toEqual({
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
});
});
it('should return expected XYState', function () {
expect(lnsAttr.getXyState()).toEqual({
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
curveType: 'CURVE_MONOTONE_X',
fittingFunction: 'Linear',
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
],
legend: { isVisible: true, position: 'right' },
preferredSeriesType: 'line',
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
valueLabels: 'hide',
});
});
describe('ParseFilters function', function () {
it('should parse default filters', function () {
expect(lnsAttr.parseFilters()).toEqual([
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
]);
});
it('should parse default and ui filters', function () {
lnsAttr = new LensAttributes(
mockIndexPattern,
reportViewConfig,
'line',
[
{ field: SERVICE_NAME, values: ['elastic-co', 'kibana-front'] },
{ field: USER_AGENT_NAME, values: ['Firefox'], notValues: ['Chrome'] },
],
'count',
{}
);
expect(lnsAttr.parseFilters()).toEqual([
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'transaction.type': 'page-load' } } },
{ meta: { index: 'apm-*' }, query: { match_phrase: { 'processor.event': 'transaction' } } },
{
meta: {
index: 'apm-*',
key: 'service.name',
params: ['elastic-co', 'kibana-front'],
type: 'phrases',
value: 'elastic-co, kibana-front',
},
query: {
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
'service.name': 'elastic-co',
},
},
{
match_phrase: {
'service.name': 'kibana-front',
},
},
],
},
},
},
{
meta: {
index: 'apm-*',
},
query: {
match_phrase: {
'user_agent.name': 'Firefox',
},
},
},
{
meta: {
index: 'apm-*',
negate: true,
},
query: {
match_phrase: {
'user_agent.name': 'Chrome',
},
},
},
]);
});
});
describe('Layer breakdowns', function () {
it('should add breakdown column', function () {
lnsAttr.addBreakdown(USER_AGENT_NAME);
expect(lnsAttr.visualization.layers).toEqual([
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
splitAccessor: 'break-down-column',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
]);
expect(lnsAttr.layers.layer1).toEqual({
columnOrder: ['x-axis-column', 'break-down-column', 'y-axis-column'],
columns: {
'break-down-column': {
dataType: 'string',
isBucketed: true,
label: 'Top values of Browser family',
operationType: 'terms',
params: {
missingBucket: false,
orderBy: { columnId: 'y-axis-column', type: 'column' },
orderDirection: 'desc',
otherBucket: true,
size: 3,
},
scale: 'ordinal',
sourceField: 'user_agent.name',
},
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [{ from: 0, label: '', to: 1000 }],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
},
incompleteColumns: {},
});
});
it('should remove breakdown column', function () {
lnsAttr.addBreakdown(USER_AGENT_NAME);
lnsAttr.removeBreakdown();
expect(lnsAttr.visualization.layers).toEqual([
{
accessors: ['y-axis-column'],
layerId: 'layer1',
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column' }],
},
]);
expect(lnsAttr.layers.layer1.columnOrder).toEqual(['x-axis-column', 'y-axis-column']);
expect(lnsAttr.layers.layer1.columns).toEqual({
'x-axis-column': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [{ from: 0, label: '', to: 1000 }],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
},
'y-axis-column': {
dataType: 'number',
isBucketed: false,
label: 'Pages loaded',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
});
});
});
});

View file

@ -0,0 +1,273 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
CountIndexPatternColumn,
DateHistogramIndexPatternColumn,
LastValueIndexPatternColumn,
OperationType,
PersistedIndexPatternLayer,
RangeIndexPatternColumn,
SeriesType,
TypedLensByValueInput,
XYState,
XYCurveType,
DataType,
} from '../../../../../../lens/public';
import {
buildPhraseFilter,
buildPhrasesFilter,
IndexPattern,
} from '../../../../../../../../src/plugins/data/common';
import { FieldLabels } from './constants';
import { DataSeries, UrlFilter } from '../types';
function getLayerReferenceName(layerId: string) {
return `indexpattern-datasource-layer-${layerId}`;
}
export class LensAttributes {
indexPattern: IndexPattern;
layers: Record<string, PersistedIndexPatternLayer>;
visualization: XYState;
filters: UrlFilter[];
seriesType: SeriesType;
reportViewConfig: DataSeries;
reportDefinitions: Record<string, string>;
constructor(
indexPattern: IndexPattern,
reportViewConfig: DataSeries,
seriesType?: SeriesType,
filters?: UrlFilter[],
metricType?: OperationType,
reportDefinitions?: Record<string, string>
) {
this.indexPattern = indexPattern;
this.layers = {};
this.filters = filters ?? [];
this.reportDefinitions = reportDefinitions ?? {};
if (typeof reportViewConfig.yAxisColumn.operationType !== undefined && metricType) {
reportViewConfig.yAxisColumn.operationType = metricType;
}
this.seriesType = seriesType ?? reportViewConfig.defaultSeriesType;
this.reportViewConfig = reportViewConfig;
this.layers.layer1 = this.getLayer();
this.visualization = this.getXyState();
}
addBreakdown(sourceField: string) {
const fieldMeta = this.indexPattern.getFieldByName(sourceField);
this.layers.layer1.columns['break-down-column'] = {
sourceField,
label: `Top values of ${FieldLabels[sourceField]}`,
dataType: fieldMeta?.type as DataType,
operationType: 'terms',
scale: 'ordinal',
isBucketed: true,
params: {
size: 3,
orderBy: { type: 'column', columnId: 'y-axis-column' },
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
},
};
this.layers.layer1.columnOrder = ['x-axis-column', 'break-down-column', 'y-axis-column'];
this.visualization.layers[0].splitAccessor = 'break-down-column';
}
removeBreakdown() {
delete this.layers.layer1.columns['break-down-column'];
this.layers.layer1.columnOrder = ['x-axis-column', 'y-axis-column'];
this.visualization.layers[0].splitAccessor = undefined;
}
getNumberColumn(sourceField: string): RangeIndexPatternColumn {
return {
sourceField,
label: this.reportViewConfig.labels[sourceField],
dataType: 'number',
operationType: 'range',
isBucketed: true,
scale: 'interval',
params: {
type: 'histogram',
ranges: [{ from: 0, to: 1000, label: '' }],
maxBars: 'auto',
},
};
}
getDateHistogramColumn(sourceField: string): DateHistogramIndexPatternColumn {
return {
sourceField,
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
};
}
getXAxis():
| LastValueIndexPatternColumn
| DateHistogramIndexPatternColumn
| RangeIndexPatternColumn {
const { xAxisColumn } = this.reportViewConfig;
const { type: fieldType, name: fieldName } = this.getFieldMeta(xAxisColumn.sourceField)!;
if (fieldType === 'date') {
return this.getDateHistogramColumn(fieldName);
}
if (fieldType === 'number') {
return this.getNumberColumn(fieldName);
}
// FIXME review my approach again
return this.getDateHistogramColumn(fieldName);
}
getFieldMeta(sourceField?: string) {
let xAxisField = sourceField;
if (xAxisField) {
const rdf = this.reportViewConfig.reportDefinitions ?? [];
const customField = rdf.find(({ field }) => field === xAxisField);
if (customField) {
if (this.reportDefinitions[xAxisField]) {
xAxisField = this.reportDefinitions[xAxisField];
} else if (customField.defaultValue) {
xAxisField = customField.defaultValue;
} else if (customField.options?.[0].field) {
xAxisField = customField.options?.[0].field;
}
}
return this.indexPattern.getFieldByName(xAxisField);
}
}
getMainYAxis() {
return {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
...this.reportViewConfig.yAxisColumn,
} as CountIndexPatternColumn;
}
getLayer() {
return {
columnOrder: ['x-axis-column', 'y-axis-column'],
columns: {
'x-axis-column': this.getXAxis(),
'y-axis-column': this.getMainYAxis(),
},
incompleteColumns: {},
};
}
getXyState(): XYState {
return {
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
curveType: 'CURVE_MONOTONE_X' as XYCurveType,
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column'],
layerId: 'layer1',
seriesType: this.seriesType ?? 'line',
palette: this.reportViewConfig.palette,
yConfig: [{ forAccessor: 'y-axis-column', color: 'green' }],
xAccessor: 'x-axis-column',
},
],
};
}
parseFilters() {
const defaultFilters = this.reportViewConfig.filters ?? [];
const parsedFilters = this.reportViewConfig.filters ? [...defaultFilters] : [];
this.filters.forEach(({ field, values = [], notValues = [] }) => {
const fieldMeta = this.indexPattern.fields.find((fieldT) => fieldT.name === field)!;
if (values?.length > 0) {
if (values?.length > 1) {
const multiFilter = buildPhrasesFilter(fieldMeta, values, this.indexPattern);
parsedFilters.push(multiFilter);
} else {
const filter = buildPhraseFilter(fieldMeta, values[0], this.indexPattern);
parsedFilters.push(filter);
}
}
if (notValues?.length > 0) {
if (notValues?.length > 1) {
const multiFilter = buildPhrasesFilter(fieldMeta, notValues, this.indexPattern);
multiFilter.meta.negate = true;
parsedFilters.push(multiFilter);
} else {
const filter = buildPhraseFilter(fieldMeta, notValues[0], this.indexPattern);
filter.meta.negate = true;
parsedFilters.push(filter);
}
}
});
return parsedFilters;
}
getJSON(): TypedLensByValueInput['attributes'] {
return {
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{
id: this.indexPattern.id!,
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: this.indexPattern.id!,
name: getLayerReferenceName('layer1'),
type: 'index-pattern',
},
],
state: {
datasourceStates: {
indexpattern: {
layers: this.layers,
},
},
visualization: this.visualization,
query: { query: '', language: 'kuery' },
filters: this.parseFilters(),
},
};
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
interface Props {
seriesId: string;
}
export function getLogsFrequencyLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'logs-frequency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
},
hasMetricType: false,
defaultFilters: [],
breakdowns: ['agent.hostname'],
filters: [],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'agent.hostname',
required: true,
},
],
};
}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';
interface Props {
seriesId: string;
}
export function getMemoryUsageLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'memory-usage',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.memory.used.pct',
label: 'Memory Usage %',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'host.hostname',
required: true,
},
],
};
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';
interface Props {
seriesId: string;
}
export function getMonitorDurationConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'uptime-duration',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar_stacked'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'monitor.duration.us',
label: 'Monitor duration (ms)',
},
hasMetricType: true,
defaultFilters: ['monitor.type', 'observer.geo.name', 'tags'],
breakdowns: [
'observer.geo.name',
'monitor.name',
'monitor.id',
'monitor.type',
'tags',
'url.port',
],
filters: [],
reportDefinitions: [
{
field: 'monitor.id',
},
],
labels: { ...FieldLabels },
};
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
interface Props {
seriesId: string;
}
export function getMonitorPingsConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'uptime-pings',
defaultSeriesType: 'bar_stacked',
seriesTypes: ['bar_stacked', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'count',
label: 'Monitor pings',
},
hasMetricType: false,
defaultFilters: ['observer.geo.name'],
breakdowns: ['monitor.status', 'observer.geo.name', 'monitor.type'],
filters: [],
palette: { type: 'palette', name: 'status' },
reportDefinitions: [
{
field: 'monitor.id',
},
{
field: 'url.full',
},
],
labels: { ...FieldLabels },
};
}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataSeries } from '../types';
import { FieldLabels } from './constants';
import { OperationType } from '../../../../../../lens/public';
interface Props {
seriesId: string;
}
export function getNetworkActivityLensConfig({ seriesId }: Props): DataSeries {
return {
id: seriesId,
reportType: 'network-activity',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'system.memory.used.pct',
},
hasMetricType: true,
defaultFilters: [],
breakdowns: ['host.hostname'],
filters: [],
labels: { ...FieldLabels, 'host.hostname': 'Host name' },
reportDefinitions: [
{
field: 'host.hostname',
required: true,
},
],
};
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import {
CLIENT_GEO_COUNTRY_NAME,
CLS_FIELD,
FCP_FIELD,
FID_FIELD,
LCP_FIELD,
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TBT_FIELD,
TRANSACTION_DURATION,
TRANSACTION_TYPE,
USER_AGENT_DEVICE,
USER_AGENT_NAME,
USER_AGENT_OS,
USER_AGENT_VERSION,
} from './data/elasticsearch_fieldnames';
export function getPerformanceDistLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId ?? 'unique-key',
reportType: 'page-load-dist',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: 'performance.metric',
},
yAxisColumn: {
operationType: 'count',
label: 'Pages loaded',
},
hasMetricType: false,
defaultFilters: [
USER_AGENT_OS,
CLIENT_GEO_COUNTRY_NAME,
USER_AGENT_DEVICE,
{
field: USER_AGENT_NAME,
nested: USER_AGENT_VERSION,
},
],
breakdowns: [USER_AGENT_NAME, USER_AGENT_OS, CLIENT_GEO_COUNTRY_NAME, USER_AGENT_DEVICE],
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
},
{
field: 'performance.metric',
custom: true,
defaultValue: TRANSACTION_DURATION,
options: [
{ label: 'Page load time', field: TRANSACTION_DURATION },
{ label: 'First contentful paint', field: FCP_FIELD },
{ label: 'Total blocking time', field: TBT_FIELD },
// FIXME, review if we need these descriptions
{ label: 'Largest contentful paint', field: LCP_FIELD, description: 'Core web vital' },
{ label: 'First input delay', field: FID_FIELD, description: 'Core web vital' },
{ label: 'Cumulative layout shift', field: CLS_FIELD, description: 'Core web vital' },
],
},
],
filters: [
buildPhraseFilter(TRANSACTION_TYPE, 'page-load', indexPattern),
buildPhraseFilter(PROCESSOR_EVENT, 'transaction', indexPattern),
],
labels: {
...FieldLabels,
[SERVICE_NAME]: 'Web Application',
[TRANSACTION_DURATION]: 'Page load time',
},
};
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import { OperationType } from '../../../../../../lens/public';
export function getServiceLatencyLensConfig({ seriesId, indexPattern }: ConfigProps): DataSeries {
return {
id: seriesId,
reportType: 'service-latency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'transaction.duration.us',
label: 'Latency',
},
hasMetricType: true,
defaultFilters: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
breakdowns: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'service.name',
required: true,
},
{
field: 'service.environment',
},
],
};
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConfigProps, DataSeries } from '../types';
import { FieldLabels } from './constants';
import { buildPhraseFilter } from './utils';
import { OperationType } from '../../../../../../lens/public';
export function getServiceThroughputLensConfig({
seriesId,
indexPattern,
}: ConfigProps): DataSeries {
return {
id: seriesId,
reportType: 'service-latency',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumn: {
operationType: 'avg' as OperationType,
sourceField: 'transaction.duration.us',
label: 'Throughput',
},
hasMetricType: true,
defaultFilters: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
breakdowns: [
'user_agent.name',
'user_agent.os.name',
'client.geo.country_name',
'user_agent.device.name',
],
filters: [buildPhraseFilter('transaction.type', 'request', indexPattern)],
labels: { ...FieldLabels },
reportDefinitions: [
{
field: 'service.name',
required: true,
},
{
field: 'service.environment',
},
],
};
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum URL_KEYS {
METRIC_TYPE = 'mt',
REPORT_TYPE = 'rt',
SERIES_TYPE = 'st',
BREAK_DOWN = 'bd',
FILTERS = 'ft',
REPORT_DEFINITIONS = 'rdf',
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import rison, { RisonValue } from 'rison-node';
import type { AllSeries, AllShortSeries } from '../hooks/use_url_strorage';
import type { SeriesUrl } from '../types';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';
import { esFilters } from '../../../../../../../../src/plugins/data/public';
import { URL_KEYS } from './url_constants';
export function convertToShortUrl(series: SeriesUrl) {
const {
metric,
seriesType,
reportType,
breakdown,
filters,
reportDefinitions,
...restSeries
} = series;
return {
[URL_KEYS.METRIC_TYPE]: metric,
[URL_KEYS.REPORT_TYPE]: reportType,
[URL_KEYS.SERIES_TYPE]: seriesType,
[URL_KEYS.BREAK_DOWN]: breakdown,
[URL_KEYS.FILTERS]: filters,
[URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions,
...restSeries,
};
}
export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') {
const allSeriesIds = Object.keys(allSeries);
const allShortSeries: AllShortSeries = {};
allSeriesIds.forEach((seriesKey) => {
allShortSeries[seriesKey] = convertToShortUrl(allSeries[seriesKey]);
});
return (
baseHref +
`/app/observability/exploratory-view#?sr=${rison.encode(allShortSeries as RisonValue)}`
);
}
export function buildPhraseFilter(field: string, value: any, indexPattern: IIndexPattern) {
const fieldMeta = indexPattern.fields.find((fieldT) => fieldT.name === field)!;
return esFilters.buildPhraseFilter(fieldMeta, value, indexPattern);
}

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { within } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import { render, mockUrlStorage, mockCore } from './rtl_helpers';
import { ExploratoryView } from './exploratory_view';
import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils';
import * as obsvInd from '../../../utils/observability_index_patterns';
describe('ExploratoryView', () => {
beforeEach(() => {
const indexPattern = getStubIndexPattern(
'apm-*',
() => {},
'@timestamp',
[
{
name: '@timestamp',
type: 'date',
esTypes: ['date'],
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
],
mockCore() as any
);
jest.spyOn(obsvInd, 'ObservabilityIndexPatterns').mockReturnValue({
getIndexPattern: jest.fn().mockReturnValue(indexPattern),
} as any);
});
it('renders exploratory view', async () => {
render(<ExploratoryView />);
await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByRole('heading', { name: /exploratory view/i });
screen.getByRole('img', { name: /visulization/i });
screen.getByText(/add series/i);
screen.getByText(/no series found, please add a series\./i);
});
});
it('can add, cancel new series', async () => {
render(<ExploratoryView />);
await fireEvent.click(screen.getByText(/add series/i));
await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByText(/select a data type to start building a series\./i);
screen.getByRole('table', { name: /this table contains 1 rows\./i });
const button = screen.getByRole('button', { name: /add/i });
within(button).getByText(/add/i);
});
await fireEvent.click(screen.getByText(/cancel/i));
await waitFor(() => {
screen.getByText(/add series/i);
});
});
it('renders lens component when there is series', async () => {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<ExploratoryView />);
await waitFor(() => {
screen.getByText(/open in lens/i);
screen.getByRole('heading', { name: /uptime pings/i });
screen.getByText(/uptime-pings-histogram/i);
screen.getByText(/Lens Embeddable Component/i);
screen.getByRole('table', { name: /this table contains 1 rows\./i });
});
});
});

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { EuiLoadingSpinner, EuiPanel, EuiTitle } from '@elastic/eui';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { ExploratoryViewHeader } from './header/header';
import { SeriesEditor } from './series_editor/series_editor';
import { useUrlStorage } from './hooks/use_url_strorage';
import { useLensAttributes } from './hooks/use_lens_attributes';
import { EmptyView } from './components/empty_view';
import { useIndexPatternContext } from './hooks/use_default_index_pattern';
import { TypedLensByValueInput } from '../../../../../lens/public';
export function ExploratoryView() {
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();
const [lensAttributes, setLensAttributes] = useState<TypedLensByValueInput['attributes'] | null>(
null
);
const { indexPattern } = useIndexPatternContext();
const LensComponent = lens?.EmbeddableComponent;
const { firstSeriesId: seriesId, firstSeries: series } = useUrlStorage();
const lensAttributesT = useLensAttributes({
seriesId,
indexPattern,
});
useEffect(() => {
setLensAttributes(lensAttributesT);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]);
return (
<EuiPanel style={{ maxWidth: 1800, minWidth: 1200, margin: '0 auto' }}>
{lens ? (
<>
<ExploratoryViewHeader lensAttributes={lensAttributes} seriesId={seriesId} />
{!indexPattern && (
<SpinnerWrap>
<EuiLoadingSpinner size="xl" />
</SpinnerWrap>
)}
{lensAttributes && seriesId && series?.reportType && series?.time ? (
<LensComponent
id="exploratoryView"
style={{ height: 550 }}
timeRange={series?.time}
attributes={lensAttributes}
/>
) : (
<EmptyView />
)}
<SeriesEditor />
</>
) : (
<EuiTitle>
<h2>
{i18n.translate('xpack.observability.overview.exploratoryView.lensDisabled', {
defaultMessage:
'Lens app is not available, please enable Lens to use exploratory view.',
})}
</h2>
</EuiTitle>
)}
</EuiPanel>
);
}
const SpinnerWrap = styled.div`
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
`;

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mockUrlStorage, render } from '../rtl_helpers';
import { ExploratoryViewHeader } from './header';
import { fireEvent } from '@testing-library/dom';
describe('ExploratoryViewHeader', function () {
it('should render properly', function () {
const { getByText } = render(
<ExploratoryViewHeader
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>
);
getByText('Open in Lens');
});
it('should be able to click open in lens', function () {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});
const { getByText, core } = render(
<ExploratoryViewHeader
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>
);
fireEvent.click(getByText('Open in Lens'));
expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1);
expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith({
attributes: { title: 'Performance distribution' },
id: '',
timeRange: {
from: 'now-15m',
to: 'now',
},
});
});
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { DataViewLabels } from '../configurations/constants';
import { useUrlStorage } from '../hooks/use_url_strorage';
interface Props {
seriesId: string;
lensAttributes: TypedLensByValueInput['attributes'] | null;
}
export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();
const { series } = useUrlStorage(seriesId);
return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiText>
<h2>
{DataViewLabels[series.reportType] ??
i18n.translate('xpack.observability.expView.heading.label', {
defaultMessage: 'Exploratory view',
})}
</h2>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="lensApp"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
onClick={() => {
if (lensAttributes) {
lens.navigateToPrefilledEditor({
id: '',
timeRange: series.time,
attributes: lensAttributes,
});
}
}}
>
{i18n.translate('xpack.observability.expView.heading.openInLens', {
defaultMessage: 'Open in Lens',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { createContext, useContext, Context, useState, useEffect } from 'react';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { AppDataType } from '../types';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { ObservabilityIndexPatterns } from '../../../../utils/observability_index_patterns';
export interface IIndexPatternContext {
indexPattern: IndexPattern;
loadIndexPattern: (dataType: AppDataType) => void;
}
export const IndexPatternContext = createContext<Partial<IIndexPatternContext>>({});
interface ProviderProps {
indexPattern?: IndexPattern;
children: JSX.Element;
}
export function IndexPatternContextProvider({
children,
indexPattern: initialIndexPattern,
}: ProviderProps) {
const [indexPattern, setIndexPattern] = useState(initialIndexPattern);
useEffect(() => {
setIndexPattern(initialIndexPattern);
}, [initialIndexPattern]);
const {
services: { data },
} = useKibana<ObservabilityPublicPluginsStart>();
const loadIndexPattern = async (dataType: AppDataType) => {
const obsvIndexP = new ObservabilityIndexPatterns(data);
const indPattern = await obsvIndexP.getIndexPattern(dataType);
setIndexPattern(indPattern!);
};
return (
<IndexPatternContext.Provider
value={{
indexPattern,
loadIndexPattern,
}}
>
{children}
</IndexPatternContext.Provider>
);
}
export const useIndexPatternContext = () => {
return useContext((IndexPatternContext as unknown) as Context<IIndexPatternContext>);
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useFetcher } from '../../../..';
import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { AllShortSeries } from './use_url_strorage';
import { ReportToDataTypeMap } from '../configurations/constants';
import {
DataType,
ObservabilityIndexPatterns,
} from '../../../../utils/observability_index_patterns';
export const useInitExploratoryView = (storage: IKbnUrlStateStorage) => {
const {
services: { data },
} = useKibana<ObservabilityPublicPluginsStart>();
const allSeriesKey = 'sr';
const allSeries = storage.get<AllShortSeries>(allSeriesKey) ?? {};
const allSeriesIds = Object.keys(allSeries);
const firstSeriesId = allSeriesIds?.[0];
const firstSeries = allSeries[firstSeriesId];
const { data: indexPattern } = useFetcher(() => {
const obsvIndexP = new ObservabilityIndexPatterns(data);
let reportType: DataType = 'apm';
if (firstSeries?.rt) {
reportType = ReportToDataTypeMap[firstSeries?.rt];
}
return obsvIndexP.getIndexPattern(reportType);
}, [firstSeries?.rt, data]);
return indexPattern;
};

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { LensAttributes } from '../configurations/lens_attributes';
import { useUrlStorage } from './use_url_strorage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { DataSeries, SeriesUrl, UrlFilter } from '../types';
interface Props {
seriesId: string;
indexPattern?: IndexPattern | null;
}
export const getFiltersFromDefs = (
reportDefinitions: SeriesUrl['reportDefinitions'],
dataViewConfig: DataSeries
) => {
const rdfFilters = Object.entries(reportDefinitions ?? {}).map(([field, value]) => {
return {
field,
values: [value],
};
}) as UrlFilter[];
// let's filter out custom fields
return rdfFilters.filter(({ field }) => {
const rdf = dataViewConfig.reportDefinitions.find(({ field: fd }) => field === fd);
return !rdf?.custom;
});
};
export const useLensAttributes = ({
seriesId,
indexPattern,
}: Props): TypedLensByValueInput['attributes'] | null => {
const { series } = useUrlStorage(seriesId);
const { breakdown, seriesType, metric: metricType, reportType, reportDefinitions = {} } =
series ?? {};
return useMemo(() => {
if (!indexPattern || !reportType) {
return null;
}
const dataViewConfig = getDefaultConfigs({
seriesId,
reportType,
indexPattern,
});
const filters: UrlFilter[] = (series.filters ?? []).concat(
getFiltersFromDefs(reportDefinitions, dataViewConfig)
);
const lensAttributes = new LensAttributes(
indexPattern,
dataViewConfig,
seriesType,
filters,
metricType,
reportDefinitions
);
if (breakdown) {
lensAttributes.addBreakdown(breakdown);
}
return lensAttributes.getJSON();
}, [
indexPattern,
breakdown,
seriesType,
metricType,
reportType,
reportDefinitions,
seriesId,
series.filters,
]);
};

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useUrlStorage } from './use_url_strorage';
import { UrlFilter } from '../types';
export interface UpdateFilter {
field: string;
value: string;
negate?: boolean;
}
export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => {
const { series, setSeries } = useUrlStorage(seriesId);
const filters = series.filters ?? [];
const removeFilter = ({ field, value, negate }: UpdateFilter) => {
const filtersN = filters.map((filter) => {
if (filter.field === field) {
if (negate) {
const notValuesN = filter.notValues?.filter((val) => val !== value);
return { ...filter, notValues: notValuesN };
} else {
const valuesN = filter.values?.filter((val) => val !== value);
return { ...filter, values: valuesN };
}
}
return filter;
});
setSeries(seriesId, { ...series, filters: filtersN });
};
const addFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter = { field };
if (negate) {
currFilter.notValues = [value];
} else {
currFilter.values = [value];
}
if (filters.length === 0) {
setSeries(seriesId, { ...series, filters: [currFilter] });
} else {
setSeries(seriesId, {
...series,
filters: [currFilter, ...filters.filter((ft) => ft.field !== field)],
});
}
};
const updateFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd) ?? {
field,
};
const currNotValues = currFilter.notValues ?? [];
const currValues = currFilter.values ?? [];
const notValues = currNotValues.filter((val) => val !== value);
const values = currValues.filter((val) => val !== value);
if (negate) {
notValues.push(value);
} else {
values.push(value);
}
currFilter.notValues = notValues.length > 0 ? notValues : undefined;
currFilter.values = values.length > 0 ? values : undefined;
const otherFilters = filters.filter(({ field: fd }) => fd !== field);
if (notValues.length > 0 || values.length > 0) {
setSeries(seriesId, { ...series, filters: [...otherFilters, currFilter] });
} else {
setSeries(seriesId, { ...series, filters: otherFilters });
}
};
const setFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd);
if (!currFilter) {
addFilter({ field, value, negate });
} else {
updateFilter({ field, value, negate });
}
};
const invertFilter = ({ field, value, negate }: UpdateFilter) => {
updateFilter({ field, value, negate: !negate });
};
return { invertFilter, setFilter, removeFilter };
};

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { createContext, useContext, Context } from 'react';
import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public';
import type { AppDataType, ReportViewTypeId, SeriesUrl, UrlFilter } from '../types';
import { convertToShortUrl } from '../configurations/utils';
import { OperationType, SeriesType } from '../../../../../../lens/public';
import { URL_KEYS } from '../configurations/url_constants';
export const UrlStorageContext = createContext<IKbnUrlStateStorage | null>(null);
interface ProviderProps {
storage: IKbnUrlStateStorage;
}
export function UrlStorageContextProvider({
children,
storage,
}: ProviderProps & { children: JSX.Element }) {
return <UrlStorageContext.Provider value={storage}>{children}</UrlStorageContext.Provider>;
}
function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl {
const { mt, st, rt, bd, ft, time, rdf, ...restSeries } = newValue;
return {
metric: mt,
reportType: rt!,
seriesType: st,
breakdown: bd,
filters: ft!,
time: time!,
reportDefinitions: rdf,
...restSeries,
};
}
interface ShortUrlSeries {
[URL_KEYS.METRIC_TYPE]?: OperationType;
[URL_KEYS.REPORT_TYPE]?: ReportViewTypeId;
[URL_KEYS.SERIES_TYPE]?: SeriesType;
[URL_KEYS.BREAK_DOWN]?: string;
[URL_KEYS.FILTERS]?: UrlFilter[];
[URL_KEYS.REPORT_DEFINITIONS]?: Record<string, string>;
time?: {
to: string;
from: string;
};
dataType?: AppDataType;
}
export type AllShortSeries = Record<string, ShortUrlSeries>;
export type AllSeries = Record<string, SeriesUrl>;
export const NEW_SERIES_KEY = 'newSeriesKey';
export function useUrlStorage(seriesId?: string) {
const allSeriesKey = 'sr';
const storage = useContext((UrlStorageContext as unknown) as Context<IKbnUrlStateStorage>);
let series: SeriesUrl = {} as SeriesUrl;
const allShortSeries = storage.get<AllShortSeries>(allSeriesKey) ?? {};
const allSeriesIds = Object.keys(allShortSeries);
const allSeries: AllSeries = {};
allSeriesIds.forEach((seriesKey) => {
allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]);
});
if (seriesId) {
series = allSeries?.[seriesId] ?? ({} as SeriesUrl);
}
const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => {
allShortSeries[seriesIdN] = convertToShortUrl(newValue);
allSeries[seriesIdN] = newValue;
return storage.set(allSeriesKey, allShortSeries);
};
const removeSeries = (seriesIdN: string) => {
delete allShortSeries[seriesIdN];
delete allSeries[seriesIdN];
storage.set(allSeriesKey, allShortSeries);
};
const firstSeriesId = allSeriesIds?.[0];
return {
storage,
setSeries,
removeSeries,
series,
firstSeriesId,
allSeries,
allSeriesIds,
firstSeries: allSeries?.[firstSeriesId],
};
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useContext } from 'react';
import { i18n } from '@kbn/i18n';
import { useHistory } from 'react-router-dom';
import { ThemeContext } from 'styled-components';
import { ExploratoryView } from './exploratory_view';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { useBreadcrumbs } from '../../../hooks/use_breadcrumbs';
import { IndexPatternContextProvider } from './hooks/use_default_index_pattern';
import {
createKbnUrlStateStorage,
withNotifyOnErrors,
} from '../../../../../../../src/plugins/kibana_utils/public/';
import { UrlStorageContextProvider } from './hooks/use_url_strorage';
import { useInitExploratoryView } from './hooks/use_init_exploratory_view';
import { WithHeaderLayout } from '../../app/layout/with_header';
export function ExploratoryViewPage() {
useBreadcrumbs([
{
text: i18n.translate('xpack.observability.overview.exploratoryView', {
defaultMessage: 'Exploratory view',
}),
},
]);
const theme = useContext(ThemeContext);
const {
services: { uiSettings, notifications },
} = useKibana<ObservabilityPublicPluginsStart>();
const history = useHistory();
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: uiSettings!.get('state:storeInSessionStorage'),
...withNotifyOnErrors(notifications!.toasts),
});
const indexPattern = useInitExploratoryView(kbnUrlStateStorage);
return (
<WithHeaderLayout
headerColor={theme.eui.euiColorEmptyShade}
bodyColor={theme.eui.euiPageBackgroundColor}
>
{indexPattern ? (
<IndexPatternContextProvider indexPattern={indexPattern!}>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
<ExploratoryView />
</UrlStorageContextProvider>
</IndexPatternContextProvider>
) : null}
</WithHeaderLayout>
);
}

View file

@ -0,0 +1,318 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { of } from 'rxjs';
import React, { ReactElement } from 'react';
import { stringify } from 'query-string';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render as reactTestLibRender, RenderOptions } from '@testing-library/react';
import { Router } from 'react-router-dom';
import { createMemoryHistory, History } from 'history';
import { CoreStart } from 'kibana/public';
import { I18nProvider } from '@kbn/i18n/react';
import { coreMock } from 'src/core/public/mocks';
import {
KibanaServices,
KibanaContextProvider,
} from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common';
import { lensPluginMock } from '../../../../../lens/public/mocks';
import { IndexPatternContextProvider } from './hooks/use_default_index_pattern';
import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_strorage';
import {
withNotifyOnErrors,
createKbnUrlStateStorage,
} from '../../../../../../../src/plugins/kibana_utils/public';
import * as fetcherHook from '../../../hooks/use_fetcher';
import * as useUrlHook from './hooks/use_url_strorage';
import * as useSeriesFilterHook from './hooks/use_series_filters';
import * as useHasDataHook from '../../../hooks/use_has_data';
import * as useValuesListHook from '../../../hooks/use_values_list';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub';
import indexPatternData from './configurations/data/test_index_pattern.json';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services';
import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns';
import { UrlFilter } from './types';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
interface KibanaProps {
services?: KibanaServices;
}
export interface KibanaProviderOptions<ExtraCore> {
core?: ExtraCore & Partial<CoreStart>;
kibanaProps?: KibanaProps;
}
interface MockKibanaProviderProps<ExtraCore extends Partial<CoreStart>>
extends KibanaProviderOptions<ExtraCore> {
children: ReactElement;
history: History;
}
type MockRouterProps<ExtraCore extends Partial<CoreStart>> = MockKibanaProviderProps<ExtraCore>;
type Url =
| string
| {
path: string;
queryParams: Record<string, string | number>;
};
interface RenderRouterOptions<ExtraCore> extends KibanaProviderOptions<ExtraCore> {
history?: History;
renderOptions?: Omit<RenderOptions, 'queries'>;
url?: Url;
}
function getSetting<T = any>(key: string): T {
if (key === 'timepicker:quickRanges') {
return ([
{
display: 'Today',
from: 'now/d',
to: 'now/d',
},
] as unknown) as T;
}
return ('MMM D, YYYY @ HH:mm:ss.SSS' as unknown) as T;
}
function setSetting$<T = any>(key: string): T {
return (of('MMM D, YYYY @ HH:mm:ss.SSS') as unknown) as T;
}
/* default mock core */
const defaultCore = coreMock.createStart();
export const mockCore: () => Partial<CoreStart & ObservabilityPublicPluginsStart> = () => {
const core: Partial<CoreStart & ObservabilityPublicPluginsStart> = {
...defaultCore,
application: {
...defaultCore.application,
getUrlForApp: () => '/app/observability',
navigateToUrl: jest.fn(),
capabilities: {
...defaultCore.application.capabilities,
observability: {
'alerting:save': true,
configureSettings: true,
save: true,
show: true,
},
},
},
uiSettings: {
...defaultCore.uiSettings,
get: getSetting,
get$: setSetting$,
},
lens: lensPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
};
return core;
};
/* Mock Provider Components */
export function MockKibanaProvider<ExtraCore extends Partial<CoreStart>>({
children,
core,
history,
kibanaProps,
}: MockKibanaProviderProps<ExtraCore>) {
const { notifications } = core!;
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
...withNotifyOnErrors(notifications!.toasts),
});
const indexPattern = mockIndexPattern;
setIndexPatterns(({
...[indexPattern],
get: async () => indexPattern,
} as unknown) as IndexPatternsContract);
return (
<KibanaContextProvider services={{ ...core }} {...kibanaProps}>
<EuiThemeProvider darkMode={false}>
<I18nProvider>
<IndexPatternContextProvider indexPattern={indexPattern}>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
{children}
</UrlStorageContextProvider>
</IndexPatternContextProvider>
</I18nProvider>
</EuiThemeProvider>
</KibanaContextProvider>
);
}
export function MockRouter<ExtraCore>({
children,
core,
history = createMemoryHistory(),
kibanaProps,
}: MockRouterProps<ExtraCore>) {
return (
<Router history={history}>
<MockKibanaProvider core={core} kibanaProps={kibanaProps} history={history}>
{children}
</MockKibanaProvider>
</Router>
);
}
/* Custom react testing library render */
export function render<ExtraCore>(
ui: ReactElement,
{
history = createMemoryHistory(),
core: customCore,
kibanaProps,
renderOptions,
url,
}: RenderRouterOptions<ExtraCore> = {}
) {
if (url) {
history = getHistoryFromUrl(url);
}
const core = {
...mockCore(),
...customCore,
};
return {
...reactTestLibRender(
<MockRouter history={history} kibanaProps={kibanaProps} core={core}>
{ui}
</MockRouter>,
renderOptions
),
history,
core,
};
}
const getHistoryFromUrl = (url: Url) => {
if (typeof url === 'string') {
return createMemoryHistory({
initialEntries: [url],
});
}
return createMemoryHistory({
initialEntries: [url.path + stringify(url.queryParams)],
});
};
export const mockFetcher = (data: any) => {
return jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data,
status: fetcherHook.FETCH_STATUS.SUCCESS,
refetch: jest.fn(),
});
};
export const mockUseHasData = () => {
const onRefreshTimeRange = jest.fn();
const spy = jest.spyOn(useHasDataHook, 'useHasData').mockReturnValue({
onRefreshTimeRange,
} as any);
return { spy, onRefreshTimeRange };
};
export const mockUseValuesList = (values?: string[]) => {
const onRefreshTimeRange = jest.fn();
const spy = jest.spyOn(useValuesListHook, 'useValuesList').mockReturnValue({
values: values ?? [],
} as any);
return { spy, onRefreshTimeRange };
};
export const mockUrlStorage = ({
data,
filters,
breakdown,
}: {
data?: AllSeries;
filters?: UrlFilter[];
breakdown?: string;
}) => {
const mockDataSeries = data || {
'performance-distribution': {
reportType: 'pld',
breakdown: breakdown || 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
...(filters ? { filters } : {}),
},
};
const allSeriesIds = Object.keys(mockDataSeries);
const firstSeriesId = allSeriesIds?.[0];
const series = mockDataSeries[firstSeriesId];
const removeSeries = jest.fn();
const setSeries = jest.fn();
const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({
firstSeriesId,
allSeriesIds,
removeSeries,
setSeries,
series,
firstSeries: mockDataSeries[firstSeriesId],
allSeries: mockDataSeries,
} as any);
return { spy, removeSeries, setSeries };
};
export function mockUseSeriesFilter() {
const removeFilter = jest.fn();
const invertFilter = jest.fn();
const setFilter = jest.fn();
const spy = jest.spyOn(useSeriesFilterHook, 'useSeriesFilters').mockReturnValue({
removeFilter,
invertFilter,
setFilter,
});
return {
spy,
removeFilter,
invertFilter,
setFilter,
};
}
const hist = createMemoryHistory();
export const mockHistory = {
...hist,
createHref: jest.fn(({ pathname }) => `/observability${pathname}`),
push: jest.fn(),
location: {
...hist.location,
pathname: '/current-path',
},
};
export const mockIndexPattern = getStubIndexPattern(
'apm-*',
() => {},
'@timestamp',
JSON.parse(indexPatternData.attributes.fields),
mockCore() as any
);

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { dataTypes, DataTypesCol } from './data_types_col';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
describe('DataTypesCol', function () {
it('should render properly', function () {
const { getByText } = render(<DataTypesCol />);
dataTypes.forEach(({ label }) => {
getByText(label);
});
});
it('should set series on change', function () {
const { setSeries } = mockUrlStorage({});
render(<DataTypesCol />);
fireEvent.click(screen.getByText(/user experience\(rum\)/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'rum' });
});
it('should set series on change on already selected', function () {
const { setSeries } = mockUrlStorage({
data: {
[NEW_SERIES_KEY]: {
dataType: 'synthetics',
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<DataTypesCol />);
const button = screen.getByRole('button', {
name: /Synthetic Monitoring/i,
});
expect(button.classList).toContain('euiButton--fill');
fireEvent.click(button);
// undefined on click selected
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: undefined });
});
});

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AppDataType } from '../../types';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
export const dataTypes: Array<{ id: AppDataType; label: string }> = [
{ id: 'synthetics', label: 'Synthetic Monitoring' },
{ id: 'rum', label: 'User Experience(RUM)' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
{ id: 'apm', label: 'APM' },
];
export function DataTypesCol() {
const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);
const { loadIndexPattern } = useIndexPatternContext();
const onDataTypeChange = (dataType?: AppDataType) => {
if (dataType) {
loadIndexPattern(dataType);
}
setSeries(NEW_SERIES_KEY, { dataType } as any);
};
const selectedDataType = series.dataType;
return (
<EuiFlexGroup direction="column" gutterSize="xs">
{dataTypes.map(({ id: dataTypeId, label }) => (
<EuiFlexItem key={dataTypeId}>
<EuiButton
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedDataType === dataTypeId ? 'primary' : 'text'}
fill={selectedDataType === dataTypeId}
onClick={() => {
onDataTypeChange(dataTypeId === selectedDataType ? undefined : dataTypeId);
}}
>
{label}
</EuiButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { render } from '../../../../../utils/test_helper';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { ReportBreakdowns } from './report_breakdowns';
import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';
describe('Series Builder ReportBreakdowns', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
it('should render properly', function () {
mockUrlStorage({});
render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);
screen.getByText('Select an option: , is selected');
screen.getAllByText('Browser family');
});
it('should set new series breakdown on change', function () {
const { setSeries } = mockUrlStorage({});
render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);
const btn = screen.getByRole('button', {
name: /select an option: Browser family , is selected/i,
hidden: true,
});
fireEvent.click(btn);
fireEvent.click(screen.getByText(/operating system/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: USER_AGENT_OS,
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
});
it('should set undefined on new series on no select breakdown', function () {
const { setSeries } = mockUrlStorage({});
render(<ReportBreakdowns dataViewSeries={dataViewSeries} />);
const btn = screen.getByRole('button', {
name: /select an option: Browser family , is selected/i,
hidden: true,
});
fireEvent.click(btn);
fireEvent.click(screen.getByText(/no breakdown/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: undefined,
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
});
});

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Breakdowns } from '../../series_editor/columns/breakdowns';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { DataSeries } from '../../types';
export function ReportBreakdowns({ dataViewSeries }: { dataViewSeries: DataSeries }) {
return <Breakdowns breakdowns={dataViewSeries.breakdowns ?? []} seriesId={NEW_SERIES_KEY} />;
}

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { ReportDefinitionCol } from './report_definition_col';
import { SERVICE_NAME } from '../../configurations/data/elasticsearch_fieldnames';
describe('Series Builder ReportDefinitionCol', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
const { setSeries } = mockUrlStorage({
data: {
'performance-dist': {
dataType: 'rum',
reportType: 'pld',
time: { from: 'now-30d', to: 'now' },
reportDefinitions: { [SERVICE_NAME]: 'elastic-co' },
},
},
});
it('should render properly', async function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);
screen.getByText('Web Application');
screen.getByText('Environment');
screen.getByText('Select an option: Page load time, is selected');
screen.getByText('Page load time');
});
it('should render selected report definitions', function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);
screen.getByText('elastic-co');
});
it('should be able to remove selected definition', function () {
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);
const removeBtn = screen.getByText(/elastic-co/i);
fireEvent.click(removeBtn);
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
dataType: 'rum',
reportDefinitions: {},
reportType: 'pld',
time: { from: 'now-30d', to: 'now' },
});
});
it('should be able to unselected selected definition', async function () {
mockUseValuesList(['elastic-co']);
render(<ReportDefinitionCol dataViewSeries={dataViewSeries} />);
const definitionBtn = screen.getByText(/web application/i);
fireEvent.click(definitionBtn);
screen.getByText('Apply');
});
});

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
import { CustomReportField } from '../custom_report_field';
import FieldValueSuggestions from '../../../field_value_suggestions';
import { DataSeries } from '../../types';
export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSeries }) {
const { indexPattern } = useIndexPatternContext();
const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);
const { reportDefinitions: rtd = {} } = series;
const { reportDefinitions, labels, filters } = dataViewSeries;
const onChange = (field: string, value?: string) => {
if (!value) {
delete rtd[field];
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: { ...rtd },
});
} else {
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: { ...rtd, [field]: value },
});
}
};
const onRemove = (field: string) => {
delete rtd[field];
setSeries(NEW_SERIES_KEY, {
...series,
reportDefinitions: rtd,
});
};
return (
<EuiFlexGroup direction="column" gutterSize="s">
{indexPattern &&
reportDefinitions.map(({ field, custom, options, defaultValue }) => (
<EuiFlexItem key={field}>
{!custom ? (
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPattern={indexPattern}
value={rtd?.[field]}
onChange={(val?: string) => onChange(field, val)}
filters={(filters ?? []).map(({ query }) => query)}
time={series.time}
width={200}
/>
</EuiFlexItem>
{rtd?.[field] && (
<EuiFlexItem grow={false}>
<EuiBadge
iconSide="right"
iconType="cross"
color="hollow"
onClick={() => onRemove(field)}
iconOnClick={() => onRemove(field)}
iconOnClickAriaLabel={'Click to remove'}
onClickAriaLabel={'Click to remove'}
>
{rtd?.[field]}
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<CustomReportField
field={field}
options={options}
defaultValue={defaultValue}
seriesId={NEW_SERIES_KEY}
/>
)}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { render } from '../../../../../utils/test_helper';
import { ReportFilters } from './report_filters';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
describe('Series Builder ReportFilters', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
mockUrlStorage({});
it('should render properly', function () {
render(<ReportFilters dataViewSeries={dataViewSeries} />);
screen.getByText('Add filter');
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { SeriesFilter } from '../../series_editor/columns/series_filter';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { DataSeries } from '../../types';
export function ReportFilters({ dataViewSeries }: { dataViewSeries: DataSeries }) {
return (
<SeriesFilter
series={dataViewSeries}
defaultFilters={dataViewSeries.defaultFilters}
seriesId={NEW_SERIES_KEY}
isNew={true}
/>
);
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col';
import { ReportTypes } from '../series_builder';
describe('ReportTypesCol', function () {
it('should render properly', function () {
render(<ReportTypesCol reportTypes={ReportTypes.rum} />);
screen.getByText('Performance distribution');
screen.getByText('KPI over time');
});
it('should display empty message', function () {
render(<ReportTypesCol reportTypes={[]} />);
screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT);
});
it('should set series on change', function () {
const { setSeries } = mockUrlStorage({});
render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />);
fireEvent.click(screen.getByText(/monitor duration/i));
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
breakdown: 'user_agent.name',
reportDefinitions: {},
reportType: 'upd',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
it('should set selected as filled', function () {
const { setSeries } = mockUrlStorage({
data: {
newSeriesKey: {
dataType: 'synthetics',
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<ReportTypesCol reportTypes={ReportTypes.synthetics} />);
const button = screen.getByRole('button', {
name: /pings histogram/i,
});
expect(button.classList).toContain('euiButton--fill');
fireEvent.click(button);
// undefined on click selected
expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'synthetics' });
});
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { ReportViewTypeId, SeriesUrl } from '../../types';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
interface Props {
reportTypes: Array<{ id: ReportViewTypeId; label: string }>;
}
export function ReportTypesCol({ reportTypes }: Props) {
const {
series: { reportType: selectedReportType, ...restSeries },
setSeries,
} = useUrlStorage(NEW_SERIES_KEY);
return reportTypes?.length > 0 ? (
<EuiFlexGroup direction="column" gutterSize="xs">
{reportTypes.map(({ id: reportType, label }) => (
<EuiFlexItem key={reportType}>
<EuiButton
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedReportType === reportType ? 'primary' : 'text'}
fill={selectedReportType === reportType}
onClick={() => {
if (reportType === selectedReportType) {
setSeries(NEW_SERIES_KEY, {
dataType: restSeries.dataType,
} as SeriesUrl);
} else {
setSeries(NEW_SERIES_KEY, {
...restSeries,
reportType,
reportDefinitions: {},
});
}
}}
>
{label}
</EuiButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
) : (
<EuiText color="subdued">{SELECTED_DATA_TYPE_FOR_REPORT}</EuiText>
);
}
export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate(
'xpack.observability.expView.reportType.noDataType',
{ defaultMessage: 'Select a data type to start building a series.' }
);

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { useUrlStorage } from '../hooks/use_url_strorage';
import { ReportDefinition } from '../types';
interface Props {
field: string;
seriesId: string;
defaultValue?: string;
options: ReportDefinition['options'];
}
export function CustomReportField({ field, seriesId, options: opts, defaultValue }: Props) {
const { series, setSeries } = useUrlStorage(seriesId);
const { reportDefinitions: rtd = {} } = series;
const onChange = (value: string) => {
setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: value } });
};
const { reportDefinitions } = series;
const NO_SELECT = 'no_select';
const options = [{ label: 'Select metric', field: NO_SELECT }, ...(opts ?? [])];
return (
<div style={{ maxWidth: 200 }}>
<EuiSuperSelect
options={options.map(({ label, field: fd, description }) => ({
value: fd,
inputDisplay: label,
}))}
valueOfSelected={reportDefinitions?.[field] || defaultValue || NO_SELECT}
onChange={(value) => onChange(value)}
/>
</div>
);
}

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiBasicTable, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { AppDataType, ReportViewTypeId, ReportViewTypes, SeriesUrl } from '../types';
import { DataTypesCol } from './columns/data_types_col';
import { ReportTypesCol } from './columns/report_types_col';
import { ReportDefinitionCol } from './columns/report_definition_col';
import { ReportFilters } from './columns/report_filters';
import { ReportBreakdowns } from './columns/report_breakdowns';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { getDefaultConfigs } from '../configurations/default_configs';
export const ReportTypes: Record<AppDataType, Array<{ id: ReportViewTypeId; label: string }>> = {
synthetics: [
{ id: 'upd', label: 'Monitor duration' },
{ id: 'upp', label: 'Pings histogram' },
],
rum: [
{ id: 'pld', label: 'Performance distribution' },
{ id: 'kpi', label: 'KPI over time' },
],
apm: [
{ id: 'svl', label: 'Latency' },
{ id: 'tpt', label: 'Throughput' },
],
logs: [
{
id: 'logs',
label: 'Logs Frequency',
},
],
metrics: [
{ id: 'cpu', label: 'CPU usage' },
{ id: 'mem', label: 'Memory usage' },
{ id: 'nwk', label: 'Network activity' },
],
};
export function SeriesBuilder() {
const { series, setSeries, allSeriesIds, removeSeries } = useUrlStorage(NEW_SERIES_KEY);
const { dataType, reportType, reportDefinitions = {}, filters = [] } = series;
const [isFlyoutVisible, setIsFlyoutVisible] = useState(!!series.dataType);
const { indexPattern } = useIndexPatternContext();
const getDataViewSeries = () => {
return getDefaultConfigs({
indexPattern,
reportType: reportType!,
seriesId: NEW_SERIES_KEY,
});
};
const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', {
defaultMessage: 'Data Type',
}),
width: '20%',
render: (val: string) => <DataTypesCol />,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.report', {
defaultMessage: 'Report',
}),
width: '20%',
render: (val: string) => (
<ReportTypesCol reportTypes={dataType ? ReportTypes[dataType] : []} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.definition', {
defaultMessage: 'Definition',
}),
width: '30%',
render: (val: string) =>
reportType && indexPattern ? (
<ReportDefinitionCol dataViewSeries={getDataViewSeries()} />
) : null,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.filters', {
defaultMessage: 'Filters',
}),
width: '25%',
render: (val: string) =>
reportType && indexPattern ? <ReportFilters dataViewSeries={getDataViewSeries()} /> : null,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', {
defaultMessage: 'Breakdowns',
}),
width: '25%',
field: 'id',
render: (val: string) =>
reportType && indexPattern ? (
<ReportBreakdowns dataViewSeries={getDataViewSeries()} />
) : null,
},
];
const addSeries = () => {
if (reportType) {
const newSeriesId = `${
reportDefinitions?.['service.name'] ||
reportDefinitions?.['monitor.id'] ||
ReportViewTypes[reportType]
}`;
const newSeriesN = {
reportType,
time: { from: 'now-30m', to: 'now' },
filters,
reportDefinitions,
} as SeriesUrl;
setSeries(newSeriesId, newSeriesN).then(() => {
removeSeries(NEW_SERIES_KEY);
setIsFlyoutVisible(false);
});
}
};
const items = [{ id: NEW_SERIES_KEY }];
let flyout;
if (isFlyoutVisible) {
flyout = (
<BottomFlyout aria-labelledby="flyoutTitle">
<EuiBasicTable
items={items as any}
columns={columns}
cellProps={{ style: { borderRight: '1px solid #d3dae6' } }}
/>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton fill iconType="plus" color="primary" onClick={addSeries}>
{i18n.translate('xpack.observability.expView.seriesBuilder.add', {
defaultMessage: 'Add',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="cross"
color="text"
onClick={() => {
removeSeries(NEW_SERIES_KEY);
setIsFlyoutVisible(false);
}}
>
{i18n.translate('xpack.observability.expView.seriesBuilder.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</BottomFlyout>
);
}
return (
<div>
{!isFlyoutVisible && (
<>
<EuiButton
iconType={isFlyoutVisible ? 'arrowDown' : 'arrowRight'}
color="primary"
iconSide="right"
onClick={() => setIsFlyoutVisible((prevState) => !prevState)}
disabled={allSeriesIds.length > 0}
>
{i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', {
defaultMessage: 'Add series',
})}
</EuiButton>
<EuiSpacer />
</>
)}
{flyout}
</div>
);
}
const BottomFlyout = styled.div`
height: 300px;
`;

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSuperDatePicker } from '@elastic/eui';
import React, { useEffect } from 'react';
import { useHasData } from '../../../../hooks/use_has_data';
import { useUrlStorage } from '../hooks/use_url_strorage';
import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges';
export interface TimePickerTime {
from: string;
to: string;
}
export interface TimePickerQuickRange extends TimePickerTime {
display: string;
}
interface Props {
seriesId: string;
}
export function SeriesDatePicker({ seriesId }: Props) {
const { onRefreshTimeRange } = useHasData();
const commonlyUsedRanges = useQuickTimeRanges();
const { series, setSeries } = useUrlStorage(seriesId);
function onTimeChange({ start, end }: { start: string; end: string }) {
onRefreshTimeRange();
setSeries(seriesId, { ...series, time: { from: start, to: end } });
}
useEffect(() => {
if (!series || !series.time) {
setSeries(seriesId, { ...series, time: { from: 'now-5h', to: 'now' } });
}
}, [seriesId, series, setSeries]);
return (
<EuiSuperDatePicker
start={series?.time?.from}
end={series?.time?.to}
onTimeChange={onTimeChange}
commonlyUsedRanges={commonlyUsedRanges}
onRefresh={onTimeChange}
showUpdateButton={false}
/>
);
}

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers';
import { fireEvent, waitFor } from '@testing-library/react';
import { SeriesDatePicker } from './index';
describe('SeriesDatePicker', function () {
it('should render properly', function () {
mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
},
});
const { getByText } = render(<SeriesDatePicker seriesId={'series-id'} />);
getByText('Last 30 minutes');
});
it('should set defaults', async function () {
const { setSeries: setSeries1 } = mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
},
},
} as any);
render(<SeriesDatePicker seriesId={'uptime-pings-histogram'} />);
expect(setSeries1).toHaveBeenCalledTimes(1);
expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', {
breakdown: 'monitor.status',
reportType: 'upp',
time: { from: 'now-5h', to: 'now' },
});
});
it('should set series data', async function () {
const { setSeries } = mockUrlStorage({
data: {
'uptime-pings-histogram': {
reportType: 'upp',
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
},
});
const { onRefreshTimeRange } = mockUseHasData();
const { getByTestId } = render(<SeriesDatePicker seriesId={'series-id'} />);
await waitFor(function () {
fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton'));
});
fireEvent.click(getByTestId('superDatePickerCommonlyUsed_Today'));
expect(onRefreshTimeRange).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'monitor.status',
reportType: 'upp',
time: { from: 'now/d', to: 'now/d' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { DataSeries } from '../../types';
import { SeriesChartTypes } from './chart_types';
import { MetricSelection } from './metric_selection';
interface Props {
series: DataSeries;
}
export function ActionsCol({ series }: Props) {
return (
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="center">
<EuiFlexItem grow={false}>
<SeriesChartTypes seriesId={series.id} defaultChartType={series.seriesTypes[0]} />
</EuiFlexItem>
{series.hasMetricType && (
<EuiFlexItem grow={false}>
<MetricSelection seriesId={series.id} isDisabled={!series.hasMetricType} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { Breakdowns } from './breakdowns';
import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers';
import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';
describe('Breakdowns', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
it('should render properly', async function () {
mockUrlStorage({});
render(<Breakdowns seriesId={'series-id'} breakdowns={dataViewSeries.breakdowns} />);
screen.getAllByText('Browser family');
});
it('should call set series on change', function () {
const { setSeries } = mockUrlStorage({ breakdown: USER_AGENT_OS });
render(<Breakdowns seriesId={'series-id'} breakdowns={dataViewSeries.breakdowns} />);
screen.getAllByText('Operating system');
fireEvent.click(screen.getByTestId('seriesBreakdown'));
fireEvent.click(screen.getByText('Browser family'));
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'user_agent.name',
reportType: 'pld',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldLabels } from '../../configurations/constants';
import { useUrlStorage } from '../../hooks/use_url_strorage';
interface Props {
seriesId: string;
breakdowns: string[];
}
export function Breakdowns({ seriesId, breakdowns = [] }: Props) {
const { setSeries, series } = useUrlStorage(seriesId);
const selectedBreakdown = series.breakdown;
const NO_BREAKDOWN = 'no_breakdown';
const onOptionChange = (optionId: string) => {
if (optionId === NO_BREAKDOWN) {
setSeries(seriesId, {
...series,
breakdown: undefined,
});
} else {
setSeries(seriesId, {
...series,
breakdown: selectedBreakdown === optionId ? undefined : optionId,
});
}
};
const items = breakdowns.map((breakdown) => ({ id: breakdown, label: FieldLabels[breakdown] }));
items.push({
id: NO_BREAKDOWN,
label: i18n.translate('xpack.observability.exp.breakDownFilter.noBreakdown', {
defaultMessage: 'No breakdown',
}),
});
const options = items.map(({ id, label }) => ({
inputDisplay: id === NO_BREAKDOWN ? label : <strong>{label}</strong>,
value: id,
dropdownDisplay: label,
}));
return (
<div style={{ width: 200 }}>
<EuiSuperSelect
fullWidth
compressed
options={options}
valueOfSelected={selectedBreakdown ?? NO_BREAKDOWN}
onChange={(value) => onOptionChange(value)}
data-test-subj={'seriesBreakdown'}
/>
</div>
);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { SeriesChartTypes, XYChartTypes } from './chart_types';
import { mockUrlStorage, render } from '../../rtl_helpers';
describe.skip('SeriesChartTypes', function () {
it('should render properly', async function () {
mockUrlStorage({});
render(<SeriesChartTypes seriesId={'series-id'} defaultChartType={'line'} />);
await waitFor(() => {
screen.getByText(/chart type/i);
});
});
it('should call set series on change', async function () {
const { setSeries } = mockUrlStorage({});
render(<SeriesChartTypes seriesId={'series-id'} defaultChartType={'line'} />);
await waitFor(() => {
screen.getByText(/chart type/i);
});
fireEvent.click(screen.getByText(/chart type/i));
fireEvent.click(screen.getByTestId('lnsXY_seriesType-bar_stacked'));
expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
breakdown: 'user_agent.name',
reportType: 'pld',
seriesType: 'bar_stacked',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(3);
});
describe('XYChartTypes', function () {
it('should render properly', async function () {
mockUrlStorage({});
render(<XYChartTypes value={'line'} onChange={jest.fn()} label={'Chart type'} />);
await waitFor(() => {
screen.getByText(/chart type/i);
});
});
});
});

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonGroup,
EuiButtonIcon,
EuiLoadingSpinner,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../../plugin';
import { useFetcher } from '../../../../..';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { SeriesType } from '../../../../../../../lens/public';
export function SeriesChartTypes({
seriesId,
defaultChartType,
}: {
seriesId: string;
defaultChartType: SeriesType;
}) {
const { series, setSeries, allSeries } = useUrlStorage(seriesId);
const seriesType = series?.seriesType ?? defaultChartType;
const onChange = (value: SeriesType) => {
Object.keys(allSeries).forEach((seriesKey) => {
const seriesN = allSeries[seriesKey];
setSeries(seriesKey, { ...seriesN, seriesType: value });
});
};
return (
<XYChartTypes
onChange={onChange}
value={seriesType}
excludeChartTypes={['bar_percentage_stacked']}
label={i18n.translate('xpack.observability.expView.chartTypes.label', {
defaultMessage: 'Chart type',
})}
/>
);
}
export interface XYChartTypesProps {
onChange: (value: SeriesType) => void;
value: SeriesType;
label?: string;
includeChartTypes?: string[];
excludeChartTypes?: string[];
}
export function XYChartTypes({
onChange,
value,
label,
includeChartTypes,
excludeChartTypes,
}: XYChartTypesProps) {
const [isOpen, setIsOpen] = useState(false);
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();
const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]);
let vizTypes = data ?? [];
if ((excludeChartTypes ?? []).length > 0) {
vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id));
}
if ((includeChartTypes ?? []).length > 0) {
vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id));
}
return loading ? (
<EuiLoadingSpinner />
) : (
<EuiPopover
isOpen={isOpen}
anchorPosition="downCenter"
button={
label ? (
<EuiButton
size="s"
color="text"
iconType={vizTypes.find(({ id }) => id === value)?.icon}
onClick={() => {
setIsOpen((prevState) => !prevState);
}}
>
{label}
</EuiButton>
) : (
<EuiButtonIcon
aria-label={vizTypes.find(({ id }) => id === value)?.label}
iconType={vizTypes.find(({ id }) => id === value)?.icon!}
onClick={() => {
setIsOpen((prevState) => !prevState);
}}
/>
)
}
closePopover={() => setIsOpen(false)}
>
<ButtonGroup
isIconOnly
buttonSize="m"
legend={i18n.translate('xpack.observability.xyChart.chartTypeLegend', {
defaultMessage: 'Chart type',
})}
name="chartType"
className="eui-displayInlineBlock"
options={vizTypes.map((t) => ({
id: t.id,
label: t.label,
title: t.label,
iconType: t.icon || 'empty',
'data-test-subj': `lnsXY_seriesType-${t.id}`,
}))}
idSelected={value}
onChange={(valueN: string) => {
onChange(valueN as SeriesType);
}}
/>
</EuiPopover>
);
}
const ButtonGroup = styled(EuiButtonGroup)`
&&& {
.euiButtonGroupButton-isSelected {
background-color: #a5a9b1 !important;
}
}
`;

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { SeriesDatePicker } from '../../series_date_picker';
interface Props {
seriesId: string;
}
export function DatePickerCol({ seriesId }: Props) {
return (
<div style={{ maxWidth: 300 }}>
<SeriesDatePicker seriesId={seriesId} />
</div>
);
}

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { FilterExpanded } from './filter_expanded';
import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
import { USER_AGENT_NAME } from '../../configurations/data/elasticsearch_fieldnames';
describe('FilterExpanded', function () {
it('should render properly', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
/>
);
screen.getByText('Browser Family');
});
it('should call go back on click', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
const goBack = jest.fn();
render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
/>
);
fireEvent.click(screen.getByText('Browser Family'));
expect(goBack).toHaveBeenCalledTimes(1);
expect(goBack).toHaveBeenCalledWith();
});
it('should call useValuesList on load', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
const { spy } = mockUseValuesList(['Chrome', 'Firefox']);
const goBack = jest.fn();
render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
/>
);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toBeCalledWith(
expect.objectContaining({
time: { from: 'now-15m', to: 'now' },
sourceField: USER_AGENT_NAME,
})
);
});
it('should filter display values', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
mockUseValuesList(['Chrome', 'Firefox']);
render(
<FilterExpanded
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
/>
);
expect(screen.queryByText('Firefox')).toBeTruthy();
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } });
expect(screen.queryByText('Firefox')).toBeFalsy();
expect(screen.getByText('Chrome')).toBeTruthy();
});
});

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, Fragment } from 'react';
import {
EuiFieldSearch,
EuiSpacer,
EuiButtonEmpty,
EuiLoadingSpinner,
EuiFilterGroup,
} from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { UrlFilter } from '../../types';
import { FilterValueButton } from './filter_value_btn';
import { useValuesList } from '../../../../../hooks/use_values_list';
interface Props {
seriesId: string;
label: string;
field: string;
goBack: () => void;
nestedField?: string;
}
export function FilterExpanded({ seriesId, field, label, goBack, nestedField }: Props) {
const { indexPattern } = useIndexPatternContext();
const [value, setValue] = useState('');
const [isOpen, setIsOpen] = useState({ value: '', negate: false });
const { series } = useUrlStorage(seriesId);
const { values, loading } = useValuesList({
sourceField: field,
time: series.time,
indexPattern,
});
const filters = series?.filters ?? [];
const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd);
const displayValues = (values || []).filter((opt) =>
opt.toLowerCase().includes(value.toLowerCase())
);
return (
<>
<EuiButtonEmpty iconType="arrowLeft" color="text" onClick={() => goBack()}>
{label}
</EuiButtonEmpty>
<EuiFieldSearch
fullWidth
value={value}
onChange={(evt) => {
setValue(evt.target.value);
}}
/>
<EuiSpacer size="s" />
{loading && (
<div style={{ textAlign: 'center' }}>
<EuiLoadingSpinner />
</div>
)}
{displayValues.map((opt) => (
<Fragment key={opt}>
<EuiFilterGroup fullWidth={true} color="primary">
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.notValues}
negate={true}
nestedField={nestedField}
seriesId={seriesId}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.values}
nestedField={nestedField}
seriesId={seriesId}
negate={false}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
</EuiFilterGroup>
<EuiSpacer size="s" />
</Fragment>
))}
</>
);
}

View file

@ -0,0 +1,238 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { FilterValueButton } from './filter_value_btn';
import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers';
import {
USER_AGENT_NAME,
USER_AGENT_VERSION,
} from '../../configurations/data/elasticsearch_fieldnames';
describe('FilterValueButton', function () {
it('should render properly', async function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
/>
);
screen.getByText('Chrome');
});
it('should render display negate state', async function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
/>
);
screen.getByText('Not Chrome');
screen.getByTitle('Not Chrome');
const btn = screen.getByRole('button');
expect(btn.classList).toContain('euiButtonEmpty--danger');
});
it('should call set filter on click', async function () {
const { setFilter, removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Firefox']}
/>
);
fireEvent.click(screen.getByText('Not Chrome'));
expect(removeFilter).toHaveBeenCalledTimes(0);
expect(setFilter).toHaveBeenCalledTimes(1);
expect(setFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
});
it('should remove filter on click if already selected', async function () {
mockUrlStorage({});
const { removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
/>
);
fireEvent.click(screen.getByText('Chrome'));
expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: false,
value: 'Chrome',
});
});
it('should change filter on negated one', async function () {
const { removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Chrome', 'Firefox']}
/>
);
fireEvent.click(screen.getByText('Not Chrome'));
expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
});
it('should force open nested', async function () {
mockUseSeriesFilter();
const { spy } = mockUseValuesList();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [
{
term: {
[USER_AGENT_NAME]: 'Chrome',
},
},
],
sourceField: 'user_agent.version',
})
);
});
it('should set isNestedOpen on click', async function () {
mockUseSeriesFilter();
const { spy } = mockUseValuesList();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toBeCalledWith(
expect.objectContaining({
filters: [
{
term: {
[USER_AGENT_NAME]: 'Chrome',
},
},
],
sourceField: USER_AGENT_VERSION,
})
);
});
it('should set call setIsNestedOpen on click selected', async function () {
mockUseSeriesFilter();
mockUseValuesList();
const setIsNestedOpen = jest.fn();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={setIsNestedOpen}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);
fireEvent.click(screen.getByText('Chrome'));
expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: false, value: '' });
});
it('should set call setIsNestedOpen on click not selected', async function () {
mockUseSeriesFilter();
mockUseValuesList();
const setIsNestedOpen = jest.fn();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: true }}
setIsNestedOpen={setIsNestedOpen}
negate={true}
allSelectedValues={['Firefox']}
nestedField={USER_AGENT_VERSION}
/>
);
fireEvent.click(screen.getByText('Not Chrome'));
expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: true, value: 'Chrome' });
});
});

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { EuiFilterButton, hexToRgb } from '@elastic/eui';
import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { useSeriesFilters } from '../../hooks/use_series_filters';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
import FieldValueSuggestions from '../../../field_value_suggestions';
interface Props {
value: string;
field: string;
allSelectedValues?: string[];
negate: boolean;
nestedField?: string;
seriesId: string;
isNestedOpen: {
value: string;
negate: boolean;
};
setIsNestedOpen: (val: { value: string; negate: boolean }) => void;
}
export function FilterValueButton({
isNestedOpen,
setIsNestedOpen,
value,
field,
negate,
seriesId,
nestedField,
allSelectedValues,
}: Props) {
const { series } = useUrlStorage(seriesId);
const { indexPattern } = useIndexPatternContext();
const { setFilter, removeFilter } = useSeriesFilters({ seriesId });
const hasActiveFilters = (allSelectedValues ?? []).includes(value);
const button = (
<FilterButton
hasActiveFilters={hasActiveFilters}
color={negate ? 'danger' : 'primary'}
onClick={() => {
if (hasActiveFilters) {
removeFilter({ field, value, negate });
} else {
setFilter({ field, value, negate });
}
if (!hasActiveFilters) {
setIsNestedOpen({ value, negate });
} else {
setIsNestedOpen({ value: '', negate });
}
}}
>
{negate
? i18n.translate('xpack.observability.expView.filterValueButton.negate', {
defaultMessage: 'Not {value}',
values: { value },
})
: value}
</FilterButton>
);
const onNestedChange = (val?: string) => {
setFilter({ field: nestedField!, value: val! });
setIsNestedOpen({ value: '', negate });
};
const forceOpenNested = isNestedOpen?.value === value && isNestedOpen.negate === negate;
const filters = useMemo(() => {
return [
{
term: {
[field]: value,
},
},
];
}, [field, value]);
return nestedField && forceOpenNested ? (
<FieldValueSuggestions
button={button}
label={'Version'}
indexPattern={indexPattern}
sourceField={nestedField}
onChange={onNestedChange}
filters={filters}
forceOpen={forceOpenNested}
anchorPosition="rightCenter"
time={series.time}
/>
) : (
button
);
}
const FilterButton = euiStyled(EuiFilterButton)`
background-color: rgba(${(props) => {
const color = props.hasActiveFilters
? props.color === 'danger'
? hexToRgb(props.theme.eui.euiColorDanger)
: hexToRgb(props.theme.eui.euiColorPrimary)
: 'initial';
return `${color[0]}, ${color[1]}, ${color[2]}, 0.1`;
}});
`;

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUrlStorage, render } from '../../rtl_helpers';
import { MetricSelection } from './metric_selection';
describe('MetricSelection', function () {
it('should render properly', function () {
render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);
screen.getByText('Average');
});
it('should display selected value', function () {
mockUrlStorage({
data: {
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);
screen.getByText('Median');
});
it('should be disabled on disabled state', function () {
render(<MetricSelection seriesId={'series-id'} isDisabled={true} />);
const btn = screen.getByRole('button');
expect(btn.classList).toContain('euiButton-isDisabled');
});
it('should call set series on change', function () {
const { setSeries } = mockUrlStorage({
data: {
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);
fireEvent.click(screen.getByText('Median'));
screen.getByText('Chart metric group');
fireEvent.click(screen.getByText('95th Percentile'));
expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});
// FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
// This should be one https://github.com/elastic/eui/issues/4629
expect(setSeries).toHaveBeenCalledTimes(3);
});
it('should call set series on change for all series', function () {
const { setSeries } = mockUrlStorage({
data: {
'page-views': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
'performance-distribution': {
reportType: 'kpi',
metric: 'median',
time: { from: 'now-15m', to: 'now' },
},
},
});
render(<MetricSelection seriesId={'series-id'} isDisabled={false} />);
fireEvent.click(screen.getByText('Median'));
screen.getByText('Chart metric group');
fireEvent.click(screen.getByText('95th Percentile'));
expect(setSeries).toHaveBeenNthCalledWith(1, 'page-views', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenNthCalledWith(2, 'performance-distribution', {
metric: '95th',
reportType: 'kpi',
time: { from: 'now-15m', to: 'now' },
});
// FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
// This should be one https://github.com/elastic/eui/issues/4629
expect(setSeries).toHaveBeenCalledTimes(6);
});
});

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonGroup, EuiPopover } from '@elastic/eui';
import { useUrlStorage } from '../../hooks/use_url_strorage';
import { OperationType } from '../../../../../../../lens/public';
const toggleButtons = [
{
id: `avg`,
label: i18n.translate('xpack.observability.expView.metricsSelect.average', {
defaultMessage: 'Average',
}),
},
{
id: `median`,
label: i18n.translate('xpack.observability.expView.metricsSelect.median', {
defaultMessage: 'Median',
}),
},
{
id: `95th`,
label: i18n.translate('xpack.observability.expView.metricsSelect.9thPercentile', {
defaultMessage: '95th Percentile',
}),
},
{
id: `99th`,
label: i18n.translate('xpack.observability.expView.metricsSelect.99thPercentile', {
defaultMessage: '99th Percentile',
}),
},
];
export function MetricSelection({
seriesId,
isDisabled,
}: {
seriesId: string;
isDisabled: boolean;
}) {
const { series, setSeries, allSeries } = useUrlStorage(seriesId);
const [isOpen, setIsOpen] = useState(false);
const [toggleIdSelected, setToggleIdSelected] = useState(series?.metric ?? 'avg');
const onChange = (optionId: OperationType) => {
setToggleIdSelected(optionId);
Object.keys(allSeries).forEach((seriesKey) => {
const seriesN = allSeries[seriesKey];
setSeries(seriesKey, { ...seriesN, metric: optionId });
});
};
const button = (
<EuiButton
onClick={() => setIsOpen((prevState) => !prevState)}
size="s"
color="text"
isDisabled={isDisabled}
>
{toggleButtons.find(({ id }) => id === toggleIdSelected)!.label}
</EuiButton>
);
return (
<EuiPopover button={button} isOpen={isOpen} closePopover={() => setIsOpen(false)}>
<EuiButtonGroup
buttonSize="m"
color="primary"
legend="Chart metric group"
options={toggleButtons}
idSelected={toggleIdSelected}
onChange={(id) => onChange(id as OperationType)}
/>
</EuiPopover>
);
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { DataSeries } from '../../types';
import { useUrlStorage } from '../../hooks/use_url_strorage';
interface Props {
series: DataSeries;
}
export function RemoveSeries({ series }: Props) {
const { removeSeries } = useUrlStorage();
const onClick = () => {
removeSeries(series.id);
};
return (
<EuiButtonIcon
aria-label={i18n.translate('xpack.observability.expView.seriesEditor.removeSeries', {
defaultMessage: 'Click to remove series',
})}
iconType="cross"
color="primary"
onClick={onClick}
size="m"
/>
);
}

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useState, Fragment } from 'react';
import {
EuiButton,
EuiPopover,
EuiSpacer,
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { FilterExpanded } from './filter_expanded';
import { DataSeries } from '../../types';
import { FieldLabels } from '../../configurations/constants';
import { SelectedFilters } from '../selected_filters';
import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
interface Props {
seriesId: string;
defaultFilters: DataSeries['defaultFilters'];
series: DataSeries;
isNew?: boolean;
}
export interface Field {
label: string;
field: string;
nested?: string;
}
export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: Props) {
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
const [selectedField, setSelectedField] = useState<Field | undefined>();
const options = defaultFilters.map((field) => {
if (typeof field === 'string') {
return { label: FieldLabels[field], field };
}
return { label: FieldLabels[field.field], field: field.field, nested: field.nested };
});
const disabled = seriesId === NEW_SERIES_KEY && !isNew;
const { setSeries, series: urlSeries } = useUrlStorage(seriesId);
const button = (
<EuiButtonEmpty
flush="left"
iconType="plus"
onClick={() => {
setIsPopoverVisible(true);
}}
isDisabled={disabled}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.addFilter', {
defaultMessage: 'Add filter',
})}
</EuiButtonEmpty>
);
const mainPanel = (
<>
<EuiSpacer size="s" />
{options.map((opt) => (
<Fragment key={opt.label}>
<EuiButton
fullWidth={true}
iconType="arrowRight"
iconSide="right"
onClick={() => {
setSelectedField(opt);
}}
>
{opt.label}
</EuiButton>
<EuiSpacer size="s" />
</Fragment>
))}
</>
);
const childPanel = selectedField ? (
<FilterExpanded
seriesId={seriesId}
field={selectedField.field}
label={selectedField.label}
nestedField={selectedField.nested}
goBack={() => {
setSelectedField(undefined);
}}
/>
) : null;
const closePopover = () => {
setIsPopoverVisible(false);
setSelectedField(undefined);
};
return (
<EuiFlexGroup wrap direction="column" gutterSize="xs" alignItems="flexStart">
{!disabled && <SelectedFilters seriesId={seriesId} series={series} isNew={isNew} />}
<EuiFlexItem grow={false}>
<EuiPopover
button={button}
isOpen={isPopoverVisible}
closePopover={closePopover}
anchorPosition="leftCenter"
>
{!selectedField ? mainPanel : childPanel}
</EuiPopover>
</EuiFlexItem>
{(urlSeries.filters ?? []).length > 0 && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
color="text"
iconType="cross"
onClick={() => {
setSeries(seriesId, { ...urlSeries, filters: undefined });
}}
isDisabled={disabled}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', {
defaultMessage: 'Clear filters',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { mockIndexPattern, mockUrlStorage, render } from '../rtl_helpers';
import { SelectedFilters } from './selected_filters';
import { getDefaultConfigs } from '../configurations/default_configs';
import { NEW_SERIES_KEY } from '../hooks/use_url_strorage';
import { USER_AGENT_NAME } from '../configurations/data/elasticsearch_fieldnames';
describe('SelectedFilters', function () {
const dataViewSeries = getDefaultConfigs({
reportType: 'pld',
indexPattern: mockIndexPattern,
seriesId: NEW_SERIES_KEY,
});
it('should render properly', async function () {
mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
render(<SelectedFilters seriesId={'series-id'} series={dataViewSeries} />);
await waitFor(() => {
screen.getByText('Chrome');
screen.getByTitle('Filter: Browser family: Chrome. Select for more filter actions.');
});
});
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { FilterLabel } from '../components/filter_label';
import { DataSeries, UrlFilter } from '../types';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
import { useSeriesFilters } from '../hooks/use_series_filters';
import { getFiltersFromDefs } from '../hooks/use_lens_attributes';
interface Props {
seriesId: string;
series: DataSeries;
isNew?: boolean;
}
export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) {
const { series } = useUrlStorage(seriesId);
const { reportDefinitions = {} } = series;
const { labels } = dataSeries;
const filters: UrlFilter[] = series.filters ?? [];
let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries);
// we don't want to display report definition filters in new series view
if (seriesId === NEW_SERIES_KEY && isNew) {
definitionFilters = [];
}
const { removeFilter } = useSeriesFilters({ seriesId });
const { indexPattern } = useIndexPatternContext();
return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? (
<EuiFlexItem>
<EuiFlexGroup wrap gutterSize="xs">
{filters.map(({ field, values, notValues }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => removeFilter({ field, value: val, negate: false })}
negate={false}
/>
</EuiFlexItem>
))}
{(notValues ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
negate={true}
removeFilter={() => removeFilter({ field, value: val, negate: true })}
/>
</EuiFlexItem>
))}
</Fragment>
))}
{definitionFilters.map(({ field, values }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => {
// FIXME handle this use case
}}
negate={false}
definitionFilter={true}
/>
</EuiFlexItem>
))}
</Fragment>
))}
</EuiFlexGroup>
</EuiFlexItem>
) : null;
}

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import { SeriesFilter } from './columns/series_filter';
import { ActionsCol } from './columns/actions_col';
import { Breakdowns } from './columns/breakdowns';
import { DataSeries } from '../types';
import { SeriesBuilder } from '../series_builder/series_builder';
import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { DatePickerCol } from './columns/date_picker_col';
import { RemoveSeries } from './columns/remove_series';
import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
export function SeriesEditor() {
const { allSeries, firstSeriesId } = useUrlStorage();
const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.name', {
defaultMessage: 'Name',
}),
field: 'id',
width: '15%',
render: (val: string) => (
<EuiText>
<EuiIcon type="dot" color="green" size="l" />{' '}
{val === NEW_SERIES_KEY ? 'new-series-preview' : val}
</EuiText>
),
},
...(firstSeriesId !== NEW_SERIES_KEY
? [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
defaultMessage: 'Filters',
}),
field: 'defaultFilters',
width: '25%',
render: (defaultFilters: string[], series: DataSeries) => (
<SeriesFilter defaultFilters={defaultFilters} seriesId={series.id} series={series} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
defaultMessage: 'Breakdowns',
}),
field: 'breakdowns',
width: '15%',
render: (val: string[], item: DataSeries) => (
<Breakdowns seriesId={item.id} breakdowns={val} />
),
},
{
name: '',
align: 'center' as const,
width: '15%',
field: 'id',
render: (val: string, item: DataSeries) => <ActionsCol series={item} />,
},
]
: []),
{
name: (
<div>
{i18n.translate('xpack.observability.expView.seriesEditor.time', {
defaultMessage: 'Time',
})}
</div>
),
width: '20%',
field: 'id',
align: 'right' as const,
render: (val: string, item: DataSeries) => <DatePickerCol seriesId={item.id} />,
},
...(firstSeriesId !== NEW_SERIES_KEY
? [
{
name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '5%',
field: 'id',
render: (val: string, item: DataSeries) => <RemoveSeries series={item} />,
},
]
: []),
];
const allSeriesKeys = Object.keys(allSeries);
const items: DataSeries[] = [];
const { indexPattern } = useIndexPatternContext();
allSeriesKeys.forEach((seriesKey) => {
const series = allSeries[seriesKey];
if (series.reportType && indexPattern) {
items.push(
getDefaultConfigs({
indexPattern,
reportType: series.reportType,
seriesId: seriesKey,
})
);
}
});
return (
<>
<EuiSpacer />
<EuiBasicTable
items={items}
rowHeader="firstName"
columns={columns}
rowProps={() => (firstSeriesId === NEW_SERIES_KEY ? {} : { height: 100 })}
noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.notFound', {
defaultMessage: 'No series found, please add a series.',
})}
cellProps={{
style: {
verticalAlign: 'top',
},
}}
/>
<EuiSpacer />
<SeriesBuilder />
</>
);
}

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PaletteOutput } from 'src/plugins/charts/public';
import {
LastValueIndexPatternColumn,
DateHistogramIndexPatternColumn,
SeriesType,
OperationType,
IndexPatternColumn,
} from '../../../../../lens/public';
import { PersistableFilter } from '../../../../../lens/common';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns';
export const ReportViewTypes = {
pld: 'page-load-dist',
kpi: 'kpi-trends',
upd: 'uptime-duration',
upp: 'uptime-pings',
svl: 'service-latency',
tpt: 'service-throughput',
logs: 'logs-frequency',
cpu: 'cpu-usage',
mem: 'memory-usage',
nwk: 'network-activity',
} as const;
type ValueOf<T> = T[keyof T];
export type ReportViewTypeId = keyof typeof ReportViewTypes;
export type ReportViewType = ValueOf<typeof ReportViewTypes>;
export interface ReportDefinition {
field: string;
required?: boolean;
custom?: boolean;
defaultValue?: string;
options?: Array<{ field: string; label: string; description?: string }>;
}
export interface DataSeries {
reportType: ReportViewType;
id: string;
xAxisColumn: Partial<LastValueIndexPatternColumn> | Partial<DateHistogramIndexPatternColumn>;
yAxisColumn: Partial<IndexPatternColumn>;
breakdowns: string[];
defaultSeriesType: SeriesType;
defaultFilters: Array<string | { field: string; nested: string }>;
seriesTypes: SeriesType[];
filters?: PersistableFilter[];
reportDefinitions: ReportDefinition[];
labels: Record<string, string>;
hasMetricType: boolean;
palette?: PaletteOutput;
}
export interface SeriesUrl {
time: {
to: string;
from: string;
};
breakdown?: string;
filters?: UrlFilter[];
seriesType?: SeriesType;
reportType: ReportViewTypeId;
metric?: OperationType;
dataType?: AppDataType;
reportDefinitions?: Record<string, string>;
}
export interface UrlFilter {
field: string;
values?: string[];
notValues?: string[];
}
export interface ConfigProps {
seriesId: string;
indexPattern: IIndexPattern;
}
export type AppDataType = 'synthetics' | 'rum' | 'logs' | 'metrics' | 'apm';

View file

@ -15,14 +15,19 @@ import {
EuiSelectableOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover';
export interface FieldValueSelectionProps {
value?: string;
label: string;
loading: boolean;
loading?: boolean;
onChange: (val?: string) => void;
values?: string[];
setQuery: Dispatch<SetStateAction<string>>;
anchorPosition?: PopoverAnchorPosition;
forceOpen?: boolean;
button?: JSX.Element;
width?: number;
}
const formatOptions = (values?: string[], value?: string): EuiSelectableOption[] => {
@ -38,6 +43,10 @@ export function FieldValueSelection({
loading,
values,
setQuery,
button,
width,
forceOpen,
anchorPosition,
onChange: onSelectionChange,
}: FieldValueSelectionProps) {
const [options, setOptions] = useState<EuiSelectableOption[]>(formatOptions(values, value));
@ -63,8 +72,9 @@ export function FieldValueSelection({
setQuery((evt.target as HTMLInputElement).value);
};
const button = (
const anchorButton = (
<EuiButton
style={width ? { width } : {}}
size="s"
iconType="arrowDown"
iconSide="right"
@ -80,9 +90,10 @@ export function FieldValueSelection({
<EuiPopover
id="popover"
panelPaddingSize="none"
button={button}
isOpen={isPopoverOpen}
button={button || anchorButton}
isOpen={isPopoverOpen || forceOpen}
closePopover={closePopover}
anchorPosition={anchorPosition}
>
<EuiSelectable
searchable

View file

@ -8,16 +8,24 @@
import React, { useState } from 'react';
import { useDebounce } from 'react-use';
import { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover';
import { useValuesList } from '../../../hooks/use_values_list';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { FieldValueSelection } from './field_value_selection';
import { ESFilter } from '../../../../../../../typings/elasticsearch';
export interface FieldValueSuggestionsProps {
value?: string;
label: string;
indexPattern: IIndexPattern;
indexPattern: IndexPattern;
sourceField: string;
onChange: (val?: string) => void;
filters: ESFilter[];
anchorPosition?: PopoverAnchorPosition;
time?: { from: string; to: string };
forceOpen?: boolean;
button?: JSX.Element;
width?: number;
}
export function FieldValueSuggestions({
@ -25,12 +33,18 @@ export function FieldValueSuggestions({
label,
indexPattern,
value,
filters,
button,
time,
width,
forceOpen,
anchorPosition,
onChange: onSelectionChange,
}: FieldValueSuggestionsProps) {
const [query, setQuery] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');
const { values, loading } = useValuesList({ indexPattern, query, sourceField });
const { values, loading } = useValuesList({ indexPattern, query, sourceField, filters, time });
useDebounce(
() => {
@ -48,6 +62,10 @@ export function FieldValueSuggestions({
setQuery={setDebouncedValue}
loading={loading}
value={value}
button={button}
forceOpen={forceOpen}
anchorPosition={anchorPosition}
width={width}
/>
);
}

View file

@ -17,12 +17,19 @@ import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overvie
import { HasDataContextProvider } from './has_data_context';
import * as pluginContext from '../hooks/use_plugin_context';
import { PluginContextValue } from './plugin_context';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
const relativeStart = '2020-10-08T06:00:00.000Z';
const relativeEnd = '2020-10-08T07:00:00.000Z';
function wrapper({ children }: { children: React.ReactElement }) {
return <HasDataContextProvider>{children}</HasDataContextProvider>;
const history = createMemoryHistory();
return (
<Router history={history}>
<HasDataContextProvider>{children}</HasDataContextProvider>
</Router>
);
}
function unregisterAll() {

View file

@ -7,6 +7,7 @@
import { uniqueId } from 'lodash';
import React, { createContext, useEffect, useState } from 'react';
import { useRouteMatch } from 'react-router-dom';
import { Alert } from '../../../alerting/common';
import { getDataHandler } from '../data_handler';
import { FETCH_STATUS } from '../hooks/use_fetcher';
@ -41,35 +42,38 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
const [hasData, setHasData] = useState<HasDataContextValue['hasData']>({});
const isExploratoryView = useRouteMatch('/exploratory-view');
useEffect(
() => {
apps.forEach(async (app) => {
try {
if (app !== 'alert') {
const params =
app === 'ux'
? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
: undefined;
if (!isExploratoryView)
apps.forEach(async (app) => {
try {
if (app !== 'alert') {
const params =
app === 'ux'
? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
: undefined;
const result = await getDataHandler(app)?.hasData(params);
const result = await getDataHandler(app)?.hasData(params);
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: result,
status: FETCH_STATUS.SUCCESS,
},
}));
}
} catch (e) {
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: result,
status: FETCH_STATUS.SUCCESS,
hasData: undefined,
status: FETCH_STATUS.FAILURE,
},
}));
}
} catch (e) {
setHasData((prevState) => ({
...prevState,
[app]: {
hasData: undefined,
status: FETCH_STATUS.FAILURE,
},
}));
}
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ChromeBreadcrumb } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { MouseEvent, useEffect } from 'react';
import { EuiBreadcrumb } from '@elastic/eui';
import { stringify } from 'query-string';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { useQueryParams } from './use_query_params';
const EMPTY_QUERY = '?';
function handleBreadcrumbClick(
breadcrumbs: ChromeBreadcrumb[],
navigateToHref?: (url: string) => Promise<void>
) {
return breadcrumbs.map((bc) => ({
...bc,
...(bc.href
? {
onClick: (event: MouseEvent) => {
if (navigateToHref && bc.href) {
event.preventDefault();
navigateToHref(bc.href);
}
},
}
: {}),
}));
}
export const makeBaseBreadcrumb = (href: string, params?: any): EuiBreadcrumb => {
if (params) {
const crumbParams = { ...params };
delete crumbParams.statusFilter;
const query = stringify(crumbParams, { skipEmptyString: true, skipNull: true });
href += query === EMPTY_QUERY ? '' : query;
}
return {
text: i18n.translate('xpack.observability.breadcrumbs.observability', {
defaultMessage: 'Observability',
}),
href,
};
};
export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => {
const params = useQueryParams();
const {
services: { chrome, application },
} = useKibana();
const setBreadcrumbs = chrome?.setBreadcrumbs;
const appPath = application?.getUrlForApp('observability-overview') ?? '';
const navigate = application?.navigateToUrl;
useEffect(() => {
if (setBreadcrumbs) {
setBreadcrumbs(
handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate)
);
}
}, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]);
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useUiSetting } from '../../../../../src/plugins/kibana_react/public';
import { UI_SETTINGS } from '../../../../../src/plugins/data/common';
import { TimePickerQuickRange } from '../components/shared/exploratory_view/series_date_picker';
export function useQuickTimeRanges() {
const timePickerQuickRanges = useUiSetting<TimePickerQuickRange[]>(
UI_SETTINGS.TIMEPICKER_QUICK_RANGES
);
return timePickerQuickRanges.map(({ from, to, display }) => ({
start: from,
end: to,
label: display,
}));
}

View file

@ -5,32 +5,58 @@
* 2.0.
*/
import { IIndexPattern } from '../../../../../src/plugins/data/common';
import { IndexPattern } from '../../../../../src/plugins/data/common';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { useFetcher } from './use_fetcher';
import { ESFilter } from '../../../../../typings/elasticsearch';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
interface Props {
export interface Props {
sourceField: string;
query?: string;
indexPattern: IIndexPattern;
indexPattern: IndexPattern;
filters?: ESFilter[];
time?: { from: string; to: string };
}
export const useValuesList = ({ sourceField, indexPattern, query, filters }: Props) => {
export const useValuesList = ({
sourceField,
indexPattern,
query = '',
filters,
time,
}: Props): { values: string[]; loading?: boolean } => {
const {
services: { data },
} = useKibana<{ data: DataPublicPluginStart }>();
const { data: values, status } = useFetcher(() => {
const { from, to } = time ?? {};
const { data: values, loading } = useFetcher(() => {
if (!sourceField || !indexPattern) {
return [];
}
return data.autocomplete.getValueSuggestions({
indexPattern,
query: query || '',
field: indexPattern.fields.find(({ name }) => name === sourceField)!,
boolFilter: filters ?? [],
useTimeRange: !(from && to),
field: indexPattern.getFieldByName(sourceField)!,
boolFilter:
from && to
? [
...(filters || []),
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
]
: filters || [],
});
}, [sourceField, query, data.autocomplete, indexPattern, filters]);
}, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]);
return { values, loading: status === 'loading' || status === 'pending' };
return { values: values as string[], loading };
};

View file

@ -55,3 +55,4 @@ export * from './typings';
export { useChartTheme } from './hooks/use_chart_theme';
export { useTheme } from './hooks/use_theme';
export { getApmTraceUrl } from './utils/get_apm_trace_url';
export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';

View file

@ -14,6 +14,7 @@ import { OverviewPage } from '../pages/overview';
import { jsonRt } from './json_rt';
import { AlertsPage } from '../pages/alerts';
import { CasesPage } from '../pages/cases';
import { ExploratoryViewPage } from '../components/shared/exploratory_view';
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
@ -115,4 +116,24 @@ export const routes = {
},
],
},
'/exploratory-view': {
handler: () => {
return <ExploratoryViewPage />;
},
params: {
query: t.partial({
rangeFrom: t.string,
rangeTo: t.string,
refreshPaused: jsonRt.pipe(t.boolean),
refreshInterval: jsonRt.pipe(t.number),
}),
},
breadcrumb: [
{
text: i18n.translate('xpack.observability.overview.exploratoryView', {
defaultMessage: 'Exploratory view',
}),
},
],
},
};

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataPublicPluginStart, IndexPattern } from '../../../../../src/plugins/data/public';
export type DataType = 'synthetics' | 'apm' | 'logs' | 'metrics' | 'rum';
const indexPatternList: Record<DataType, string> = {
synthetics: 'synthetics_static_index_pattern_id',
apm: 'apm_static_index_pattern_id',
rum: 'apm_static_index_pattern_id',
logs: 'logs_static_index_pattern_id',
metrics: 'metrics_static_index_pattern_id',
};
const appToPatternMap: Record<DataType, string> = {
synthetics: 'heartbeat-*',
apm: 'apm-*',
rum: 'apm-*',
logs: 'logs-*,filebeat-*',
metrics: 'metrics-*,metricbeat-*',
};
export class ObservabilityIndexPatterns {
data?: DataPublicPluginStart;
constructor(data: DataPublicPluginStart) {
this.data = data;
}
async createIndexPattern(app: DataType) {
if (!this.data) {
throw new Error('data is not defined');
}
const pattern = appToPatternMap[app];
const fields = await this.data.indexPatterns.getFieldsForWildcard({
pattern,
});
return await this.data.indexPatterns.createAndSave({
fields,
title: pattern,
id: indexPatternList[app],
timeFieldName: '@timestamp',
});
}
async getIndexPattern(app: DataType): Promise<IndexPattern | undefined> {
if (!this.data) {
throw new Error('data is not defined');
}
try {
return await this.data?.indexPatterns.get(indexPatternList[app]);
} catch (e) {
return await this.createIndexPattern(app || 'apm');
}
}
}

View file

@ -7,7 +7,14 @@
"declaration": true,
"declarationMap": true
},
"include": ["common/**/*", "public/**/*", "server/**/*", "typings/**/*"],
"include": [
"common/**/*",
"public/**/*",
"public/**/*.json",
"server/**/*",
"typings/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },

View file

@ -68,18 +68,21 @@ export class UptimePlugin
return UptimeDataHelper(coreStart);
};
plugins.observability.dashboard.register({
appName: 'uptime',
hasData: async () => {
const dataHelper = await getUptimeDataHelper();
const status = await dataHelper.indexStatus();
return status.docCount > 0;
},
fetchData: async (params: FetchDataParams) => {
const dataHelper = await getUptimeDataHelper();
return await dataHelper.overviewData(params);
},
});
if (plugins.observability) {
plugins.observability.dashboard.register({
appName: 'uptime',
hasData: async () => {
const dataHelper = await getUptimeDataHelper();
const status = await dataHelper.indexStatus();
return status.docCount > 0;
},
fetchData: async (params: FetchDataParams) => {
const dataHelper = await getUptimeDataHelper();
return await dataHelper.overviewData(params);
},
});
}
core.application.register({
id: PLUGIN.ID,