Skip to content

Commit 6e50acb

Browse files
App Canary - table improvements (#182)
* Implement posit-sdk * Provide better formatted instructions, and display the CANARY_GUIDS as text * Add custom User-Agent to the request that visits the monitored content * Reformat table so only name is a link to dashboard_url, display owner name and email link * Make table larger * Use dashboard_url without any additional manual manipulation * Correct content title, add logs, open links in new tab * Update .gitignore * Fix log_url logic to only exclude viewer role * Update extensions/app-canary/app-canary.qmd Co-authored-by: Toph Allen <[email protected]> --------- Co-authored-by: Toph Allen <[email protected]>
1 parent 64dd971 commit 6e50acb

File tree

2 files changed

+137
-26
lines changed

2 files changed

+137
-26
lines changed

extensions/app-canary/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/.quarto/
2+
*.quarto_ipynb
23

34
/_*.local
45

extensions/app-canary/app-canary.qmd

Lines changed: 136 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
---
22
title: "App Canary - Application Health Monitor"
3-
format: email
3+
format:
4+
email:
5+
page-layout: full
46
---
57

68
```{python}
79
#| echo: false
10+
#| label: setup
811
912
import json
1013
import os
@@ -47,11 +50,21 @@ else:
4750
4851
# Instantiate a Connect client using posit-sdk where api_key and url are automatically read from our environment vars
4952
client = connect.Client()
53+
```
54+
55+
```{python}
56+
#| echo: false
57+
#| label: data-gathering
5058
5159
if not show_instructions:
5260
# Proceed to validate our monitored GUIDS
61+
5362
# Headers for Connect API
54-
headers = {"Authorization": f"Key {api_key}"}
63+
headers = {
64+
"Authorization": f"Key {api_key}",
65+
# Set a custom user agent to enable filtering App Canary activity in Connect instrumentation data
66+
"User-Agent": "AppCanary/1.0",
67+
}
5568
5669
# Check if server is reachable, would only be a potential problem during local dev
5770
try:
@@ -96,15 +109,54 @@ if not show_instructions:
96109
"guid": guid
97110
}
98111
112+
def get_user(user_guid):
113+
try:
114+
user = client.users.get(user_guid)
115+
return user
116+
except Exception as e:
117+
raise RuntimeError(f"Error getting user: {str(e)}")
118+
99119
# Function to validate app health (simple HTTP 200 check)
100120
def validate_app(guid):
101121
# Get app details
102122
content = get_content(guid)
103-
app_name = content.get("title", "Unknown")
123+
app_name = content.get("title", "")
124+
# Title is optional, if not set use name
125+
if not app_name:
126+
app_name = content.get("name", "")
104127
105-
# Extract content_url if available
128+
# Ensure we have a valid URL
106129
dashboard_url = content.get("dashboard_url", "")
130+
content_guid = content.get("guid", guid) # Use the passed guid if not in content
131+
132+
# Initialize default owner information
133+
owner_email = ""
134+
owner_full_name = ""
107135
136+
# Get additional data if we don't have an error when attempting to get the content details
137+
if not str(app_name).startswith("ERROR:"):
138+
# Get content owner details
139+
try:
140+
owner_guid = content.get("owner_guid")
141+
if owner_guid:
142+
# Get owner details
143+
owner = get_user(owner_guid)
144+
owner_email = owner.get("email", "")
145+
owner_first_name = owner.get("first_name", "")
146+
owner_last_name = owner.get("last_name", "")
147+
owner_full_name = f"{owner_first_name} {owner_last_name}".strip()
148+
if not owner_full_name: # Handle case where both names are empty
149+
owner_full_name = "Unknown"
150+
except Exception as e:
151+
# If there's an error getting the owner, keep the defaults
152+
print(f"Warning: Could not retrieve owner for {guid}: {str(e)}")
153+
154+
# Compose URL to logs if we have a dashboard URL, only owner/editor have access
155+
if dashboard_url and content.get("app_role") != "viewer":
156+
logs_url = f"{dashboard_url}/logs"
157+
else:
158+
logs_url = ""
159+
108160
try:
109161
app_url = f"{connect_server}/content/{guid}"
110162
app_response = requests.get(
@@ -115,18 +167,30 @@ if not show_instructions:
115167
)
116168
117169
return {
170+
# App details
118171
"guid": guid,
119172
"name": app_name,
120173
"dashboard_url": dashboard_url,
174+
"logs_url": logs_url,
175+
# Owner details
176+
"owner_name": owner_full_name,
177+
"owner_email": owner_email,
178+
# Monitoring status
121179
"status": "PASS" if app_response.status_code >= 200 and app_response.status_code < 300 else "FAIL",
122180
"http_code": app_response.status_code
123181
}
124182
125183
except Exception as e:
126184
return {
185+
# App details
127186
"guid": guid,
128187
"name": app_name,
129188
"dashboard_url": dashboard_url,
189+
"logs_url": logs_url,
190+
# Owner details
191+
"owner_name": owner_full_name,
192+
"owner_email": owner_email,
193+
# Monitoring status
130194
"status": "FAIL",
131195
"http_code": str(e)
132196
}
@@ -139,28 +203,14 @@ if not show_instructions:
139203
# Convert results to DataFrame for easy display
140204
df = pd.DataFrame(results)
141205
142-
# Reorder columns to put name first
143-
# Create a dynamic column order with name first, status and http_code last
144-
if 'name' in df.columns:
145-
cols = ['name'] # Start with name
146-
# Add any other columns except name, status, and http_code
147-
middle_cols = [col for col in df.columns if col not in ['name', 'status', 'http_code']]
148-
cols.extend(middle_cols)
149-
# Add status and http_code at the end
150-
if 'status' in df.columns:
151-
cols.append('status')
152-
if 'http_code' in df.columns:
153-
cols.append('http_code')
154-
# Reorder the DataFrame
155-
df = df[cols]
156-
157206
# Store the current time
158207
check_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
159208
```
160209

161210

162211
```{python}
163212
#| echo: false
213+
#| label: display
164214
165215
# Create a table with basic styling
166216
if not show_instructions and not df.empty:
@@ -175,34 +225,94 @@ if not show_instructions and not df.empty:
175225
<div style="padding: 5px 0;">{canary_guids_str}</div>
176226
</div>
177227
"""))
178-
228+
179229
# First create links for name and guid columns
180230
df_display = df.copy()
181231
182-
# Process the DataFrame rows to add HTML links
232+
# Process the DataFrame rows to add HTML links and format owner info
183233
for i in range(len(df_display)):
184-
if not pd.isna(df_display.loc[i, 'dashboard_url']) and df_display.loc[i, 'dashboard_url']:
234+
# Format app name links only (not guid)
235+
if 'dashboard_url' in df_display.columns and not pd.isna(df_display.loc[i, 'dashboard_url']) and df_display.loc[i, 'dashboard_url']:
185236
url = df_display.loc[i, 'dashboard_url']
186-
df_display.loc[i, 'name'] = f"<a href='{url}' target='_blank'>{df_display.loc[i, 'name']}</a>"
187-
df_display.loc[i, 'guid'] = f"<a href='{url}' target='_blank'>{df_display.loc[i, 'guid']}</a>"
237+
app_name = df_display.loc[i, 'name']
238+
app_guid = df_display.loc[i, 'guid']
239+
240+
# Create simple markdown link for name only
241+
df_display.loc[i, 'name'] = f"<a href='{url}' target='_blank'>{app_name}</a>"
242+
243+
# Format logs URL using markdown instead of HTML (similar to owner email)
244+
if 'logs_url' in df_display.columns and df_display.loc[i, 'logs_url'] is not None and str(df_display.loc[i, 'logs_url']).strip():
245+
# Use a simple icon since we can't use custom SVG in emails easily
246+
logs_icon = "📋"
247+
logs_url = df_display.loc[i, 'logs_url']
248+
df_display.loc[i, 'logs'] = f"<a href='{logs_url}' target='_blank' style='text-decoration:none;'>{logs_icon}</a>"
249+
250+
else:
251+
df_display.loc[i, 'logs'] = ""
252+
253+
# Format owner name with email icon link
254+
owner_name = df_display.loc[i, 'owner_name'] if not pd.isna(df_display.loc[i, 'owner_name']) else "Unknown"
255+
owner_email = df_display.loc[i, 'owner_email'] if not pd.isna(df_display.loc[i, 'owner_email']) else ""
256+
257+
if owner_email:
258+
# Use a simple icon since we can't use custom SVG in emails easily
259+
email_icon = "✉️"
260+
df_display.loc[i, 'owner_display'] = f"{owner_name} <a href='mailto:{owner_email}' style='text-decoration:none;'>{email_icon}</a>"
261+
else:
262+
df_display.loc[i, 'owner_display'] = owner_name
188263
189264
# Remove dashboard_url column since the links are embedded in the other columns
190265
if 'dashboard_url' in df_display.columns:
191266
df_display = df_display.drop(columns=['dashboard_url'])
192267
268+
# Remove raw owner columns in favor of our formatted display column
269+
if 'owner_name' in df_display.columns:
270+
df_display = df_display.drop(columns=['owner_name'])
271+
if 'owner_email' in df_display.columns:
272+
df_display = df_display.drop(columns=['owner_email'])
273+
274+
# Reorder columns to match requested layout
275+
column_order = ['name', 'guid', 'status', 'http_code', 'logs', 'owner_display']
276+
# Only include columns that exist in df_display
277+
ordered_columns = [col for col in column_order if col in df_display.columns]
278+
df_display = df_display[ordered_columns]
279+
193280
# Create GT table
194281
gt_tbl = GT(df_display)
195282
196-
# Apply styling to status column
283+
# Tell great_tables to render markdown content in these columns
284+
gt_tbl = gt_tbl.fmt_markdown(columns=['name', 'owner_display', 'logs'])
285+
286+
# Apply styling to columns
197287
gt_tbl = (gt_tbl
288+
# Status column styling - green for PASS, red for FAIL
198289
.tab_style(
199290
style.fill("green"),
200291
locations=loc.body(columns="status", rows=lambda df: df["status"] == "PASS")
201292
)
202293
.tab_style(
203294
style.fill("red"),
204295
locations=loc.body(columns="status", rows=lambda df: df["status"] == "FAIL")
205-
))
296+
)
297+
# Set column labels for better presentation
298+
.cols_label(
299+
owner_display="Owner",
300+
name="Name",
301+
guid="Content GUID",
302+
status="Status",
303+
http_code="HTTP Code",
304+
logs="Logs"
305+
)
306+
# Override default column alignment for better presentation
307+
.cols_align(
308+
align='center',
309+
columns=["status", "http_code", "logs"]
310+
)
311+
.tab_options(
312+
container_width="100%"
313+
)
314+
)
315+
206316
elif show_instructions:
207317
# Create a callout box for instructions
208318
instructions_html = ""

0 commit comments

Comments
 (0)