Skip to content

Commit 1c83831

Browse files
authored
Added custom query editor to web (#74)
1 parent af2a178 commit 1c83831

File tree

14 files changed

+407
-3
lines changed

14 files changed

+407
-3
lines changed

TinyInsights.Web/Components/LoggedInMenuItems.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<RadzenPanelMenuItem Text="Events" Icon="touch_app" Path="/analytics/events" />
88
<RadzenPanelMenuItem Text="Users" Icon="people" Path="/analytics/users" />
99
<RadzenPanelMenuItem Text="Devices" Icon="smartphone" Path="/analytics/devices" />
10+
<RadzenPanelMenuItem Text="Custom query" Icon="find_in_page" Path="/query" />
1011
</div>
1112
@code {
1213

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
@inject TooltipService tooltipService
2+
3+
<div class="history-item">
4+
<span class="query" @ref="querySpanRef" @onmouseover="OnQueryMouseOver">@(Name ?? Query)</span>
5+
<RadzenButton Icon="play_arrow" Size="ButtonSize.Small" Click="RunClicked" />
6+
</div>
7+
8+
@code {
9+
[Parameter]
10+
public string Query { get; set; } = string.Empty;
11+
[Parameter]
12+
public string? Name { get; set; }
13+
[Parameter]
14+
public EventCallback RunClicked { get; set; }
15+
16+
private ElementReference querySpanRef;
17+
18+
private void ShowTooltip(ElementReference elementReference)
19+
{
20+
if(Name is not null)
21+
{
22+
return;
23+
}
24+
25+
tooltipService.Open(elementReference, Name ?? Query, new TooltipOptions() { Style = "background: #757575; color: white;" });
26+
}
27+
28+
private void OnQueryMouseOver()
29+
{
30+
ShowTooltip(querySpanRef);
31+
}
32+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.history-item {
2+
display: flex;
3+
align-items: center;
4+
gap: 0.5rem;
5+
}
6+
7+
.query {
8+
flex: 1;
9+
word-break: break-all;
10+
font-family: Monaco, Menlo, Courier;
11+
display: -webkit-box;
12+
-webkit-line-clamp: 2;
13+
-webkit-box-orient: vertical;
14+
overflow: hidden;
15+
text-overflow: ellipsis;
16+
max-height: 2.8em; /* fallback for browsers not supporting line-clamp */
17+
}

TinyInsights.Web/Pages/Query.razor

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
@page "/query"
2+
@using Blazored.LocalStorage
3+
4+
@inherits TinyInsightsComponentBase
5+
6+
@inject ILocalStorageService localStorage
7+
@inject IInsightsService insightsService
8+
9+
<div>
10+
<RadzenLayout Style="grid-template-columns: auto 1fr auto; grid-template-areas: 'rz-header rz-header rz-header' 'rz-sidebar rz-body rz-right-sidebar'">
11+
<RadzenSidebar>
12+
<RadzenCard>
13+
<RadzenStack>
14+
<h2>Predefined queries</h2>
15+
@foreach (var item in predefinedQueries)
16+
{
17+
<QueryItem Name="@item.Key" RunClicked="@(async () => await RunHistoryQuery(item.Value))" />
18+
}
19+
</RadzenStack>
20+
</RadzenCard>
21+
</RadzenSidebar>
22+
<RadzenBody>
23+
<RadzenCard>
24+
<RadzenStack>
25+
<h2>Custom query</h2>
26+
<span>Note, no global filter will apply when running this query!</span>
27+
28+
<RadzenButton Text="Run" Icon="play_arrow" class="run_button" Disabled="@(isRunDisabled || isLoading)" Click="RunQuery" />
29+
<b>Time range</b>
30+
<RadzenRadioButtonList TValue="string" Data="@timeRanges" @bind-Value="selectedTimeRange" TextProperty="Key" ValueProperty="Value" />
31+
32+
33+
<StandaloneCodeEditor Id="monaco-query-editor" ConstructionOptions="EditorConstructionOptions" OnKeyUp="HandleKeyUp" />
34+
35+
@if(isLoading)
36+
{
37+
<RadzenProgressBarCircular Value="100" ShowValue="false" Mode="ProgressBarMode.Indeterminate" />
38+
}
39+
40+
else if (queryResult is not null && queryResult.Tables is not null)
41+
{
42+
int tableIndex = 0;
43+
foreach (var table in queryResult.Tables)
44+
{
45+
var filteredIndexes = GetFilteredColumnIndexes(table);
46+
var expandedRowIndex = expandedRowIndexes.Count > tableIndex ? expandedRowIndexes[tableIndex] : null;
47+
<RadzenDataGrid Data="@table.Rows" TItem="List<object>" ShowPagingSummary="true" PageSize="20" AllowPaging="true" AllowSorting="true" class="rz-datagrid-table">
48+
<Columns>
49+
<RadzenDataGridColumn TItem="List<object>" Title="" Width="40px">
50+
<Template Context="row">
51+
52+
</Template>
53+
</RadzenDataGridColumn>
54+
@foreach (var idx in filteredIndexes)
55+
{
56+
<RadzenDataGridColumn @key=idx TItem="List<object>" Title="@table.Columns[idx].Name" Property="@PropertyAccess.GetDynamicPropertyExpression(idx.ToString(),typeof(object))">
57+
<Template>
58+
@context[idx]
59+
</Template>
60+
</RadzenDataGridColumn>
61+
}
62+
</Columns>
63+
<Template Context="row">
64+
@{
65+
var rowIndex = table.Rows.IndexOf(row);
66+
bool isExpanded = expandedRowIndexes.Count > tableIndex && expandedRowIndexes[tableIndex] == rowIndex;
67+
}
68+
69+
@for (int i = 0; i < table.Columns.Count; i++)
70+
{
71+
<tr>
72+
<td><strong>@table.Columns[i].Name:</strong></td><td> @row[i]</td>
73+
</tr>
74+
}
75+
</Template>
76+
</RadzenDataGrid>
77+
tableIndex++;
78+
}
79+
}
80+
</RadzenStack>
81+
</RadzenCard>
82+
</RadzenBody>
83+
<RadzenSidebar Style="width: 500px; grid-area: rz-right-sidebar">
84+
<RadzenCard>
85+
<RadzenStack>
86+
<h2>Query history</h2>
87+
@foreach(var item in history)
88+
{
89+
<QueryItem Query="@item" RunClicked="@(async () => await RunHistoryQuery(item))" />
90+
}
91+
</RadzenStack>
92+
</RadzenCard>
93+
</RadzenSidebar>
94+
</RadzenLayout>
95+
</div>
96+
97+
@code {
98+
private const string HistoryLocalStorageKey = "history";
99+
private readonly IReadOnlyList<string> generalColumns = ["timestamp", "user_Id", "client_OS", "application_Version"];
100+
private readonly IReadOnlyList<string> customEventsColumns = ["name"];
101+
private readonly IReadOnlyList<string> exceptionsColumns = ["problemId", "type", "assembly", "method", "outerType", "outerMessage"];
102+
private readonly IReadOnlyList<string> dependenciesColumns = ["target", "type", "name", "resultCode", "duration", "success"];
103+
private readonly IReadOnlyList<string> pageViewsColumns = ["name"];
104+
105+
private StandaloneCodeEditor? editor;
106+
private bool isRunDisabled = true;
107+
private bool isLoading = false;
108+
109+
private string? currentQuery;
110+
111+
private QueryResult? queryResult;
112+
private List<string> history = [];
113+
114+
// Track expanded row index for each table
115+
private List<int?> expandedRowIndexes = new();
116+
117+
118+
private string selectedTimeRange = "3d";
119+
120+
121+
// Helper to get filtered column indexes for a table
122+
private List<int> GetFilteredColumnIndexes(Table table)
123+
{
124+
var indexes = new List<int>();
125+
for (int i = 0; i < table.Columns.Count; i++)
126+
{
127+
if (table.Columns.Count <= 5 || FilterColumn(table.Columns[i].Name))
128+
{
129+
indexes.Add(i);
130+
}
131+
}
132+
return indexes;
133+
}
134+
135+
protected override async Task OnParametersSetAsync()
136+
{
137+
await base.OnParametersSetAsync();
138+
139+
history = await GetHistory();
140+
expandedRowIndexes.Clear();
141+
142+
if (editor is not null)
143+
{
144+
await editor.SetValue(string.Empty);
145+
}
146+
}
147+
148+
private async Task RunQuery()
149+
{
150+
try
151+
{
152+
if (editor is not null)
153+
{
154+
var text = await editor.GetValue(false);
155+
156+
isLoading = true;
157+
StateHasChanged();
158+
159+
CancelCurrentOperation();
160+
161+
var textToHistory = text;
162+
var parts = text.Split("|");
163+
164+
if (text.Contains("timestamp > ago("))
165+
{
166+
selectedTimeRange = CustomTimeRangeValue;
167+
}
168+
else if (selectedTimeRange != CustomTimeRangeValue)
169+
{
170+
text = $"{text} | where timestamp > ago({selectedTimeRange})";
171+
}
172+
173+
if (parts is not null && !parts.Last().Contains("limit"))
174+
{
175+
text = $"{text.Trim()} | limit 100";
176+
}
177+
178+
179+
180+
currentQuery = text;
181+
queryResult = await insightsService.RunQuery(text, CancellationToken);
182+
expandedRowIndexes.Clear();
183+
184+
await SetHistory(textToHistory);
185+
}
186+
}
187+
finally
188+
{
189+
isLoading = false;
190+
StateHasChanged();
191+
}
192+
}
193+
194+
private async Task HandleKeyUp()
195+
{
196+
if (editor is not null)
197+
{
198+
var text = await editor.GetValue(false);
199+
200+
if(string.IsNullOrWhiteSpace(text))
201+
{
202+
isRunDisabled = true;
203+
return;
204+
}
205+
206+
isRunDisabled = false;
207+
}
208+
}
209+
210+
private async Task<List<string>> GetHistory()
211+
{
212+
var storedHistory = await localStorage.GetItemAsync<List<string>>(HistoryLocalStorageKey);
213+
return storedHistory ?? [];
214+
}
215+
216+
private async Task SetHistory(string value)
217+
{
218+
history.RemoveAll(q => q == value);
219+
history.Insert(0, value);
220+
221+
if (history.Count > 10)
222+
{
223+
history = history.Take(10).ToList();
224+
}
225+
226+
await localStorage.SetItemAsync(HistoryLocalStorageKey, history);
227+
}
228+
229+
private StandaloneEditorConstructionOptions EditorConstructionOptions(StandaloneCodeEditor editor)
230+
{
231+
//Make sure editor is loaded. Ugly, but it will thow exception if using @ref or setting it directly in this method
232+
_= Task.Run(async() => { await Task.Delay(1000); this.editor = editor; });
233+
234+
return new StandaloneEditorConstructionOptions
235+
{
236+
AutomaticLayout = true,
237+
Placeholder = "Type your query here",
238+
Minimap = new EditorMinimapOptions() {Enabled = false}
239+
};
240+
}
241+
242+
private async Task RunHistoryQuery(string query)
243+
{
244+
if (editor is not null)
245+
{
246+
await editor.SetValue(query);
247+
isRunDisabled = false;
248+
await RunQuery();
249+
}
250+
}
251+
252+
private bool FilterColumn(string name)
253+
{
254+
var include = generalColumns.Contains(name);
255+
256+
if(include)
257+
{
258+
return true;
259+
}
260+
261+
if(currentQuery is not null)
262+
{
263+
if(currentQuery.StartsWith("customEvents"))
264+
{
265+
include = customEventsColumns.Contains(name);
266+
}
267+
else if (currentQuery.StartsWith("exceptions"))
268+
{
269+
include = exceptionsColumns.Contains(name);
270+
}
271+
else if (currentQuery.StartsWith("dependencies"))
272+
{
273+
include = dependenciesColumns.Contains(name);
274+
}
275+
else if (currentQuery.StartsWith("pageViews"))
276+
{
277+
include = pageViewsColumns.Contains(name);
278+
}
279+
}
280+
281+
return include;
282+
}
283+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace TinyInsights.Web.Pages;
2+
public partial class Query
3+
{
4+
private Dictionary<string, string> predefinedQueries = new()
5+
{
6+
{"Latest errors", "exceptions" },
7+
{"Latest crashes", "exceptions | where customDimensions.IsCrash == true" },
8+
{"Exceptions per day", "exceptions | summarize count() by bin(timestamp, 1d)" },
9+
{"Crashes per day", "exceptions | where customDimensions.IsCrash == true | summarize count() by bin(timestamp, 1d)" },
10+
{"Users per day", "customEvents | summarize dcount(user_Id) by bin(timestamp, 1d)" }
11+
};
12+
13+
private const string CustomTimeRangeValue = "custom";
14+
15+
private Dictionary<string, string> timeRanges = new()
16+
{
17+
{"Last hours", "1h" },
18+
{"Last 4 hours", "4h" },
19+
{"Last 12 hours", "12h" },
20+
{"Last 24 hours", "24h" },
21+
{"Last 48 hours", "48h" },
22+
{ "Last 3 days", "3d" },
23+
{ "Last 7 days", "7d" },
24+
{ "Last 30 days", "30d" },
25+
{"Custom", CustomTimeRangeValue }
26+
};
27+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
::deep .rz-sidebar
2+
{
3+
background-color:transparent;
4+
padding:16px;
5+
}
6+
7+
::deep #monaco-query-editor { /* applies to a specific editor instance */
8+
height: 300px;
9+
border: solid;
10+
border-color: rgb(238, 238, 238);
11+
border-width: 1px;
12+
padding: 10px;
13+
}
14+
15+
::deep .monaco-editor-container { /* applies to all editor instances */
16+
height: 500px;
17+
}
18+
19+
::deep .run_button
20+
{
21+
width:100px;
22+
}
23+
24+
.query {
25+
font-family: Monaco, Menlo, Courier;
26+
}

0 commit comments

Comments
 (0)