33from shiny import App , ui , render , reactive
44from posit .connect import Client
55from io import BytesIO , TextIOWrapper
6+ import os
67import requests
78from prometheus_client import parser
89from typing import Dict , List , Tuple , Optional
8990 padding-bottom: 10px;
9091 border-bottom: 1px solid rgba(55, 53, 47, 0.09);
9192 }
93+ .card-title a {
94+ color: #37352f;
95+ text-decoration: none;
96+ display: flex;
97+ align-items: center;
98+ gap: 6px;
99+ }
100+ .card-title a:hover {
101+ color: #2383e2;
102+ }
103+ .external-link-icon {
104+ width: 14px;
105+ height: 14px;
106+ opacity: 0.5;
107+ }
108+ .card-title a:hover .external-link-icon {
109+ opacity: 1;
110+ }
92111 .section-grid-4 {
93112 display: grid;
94113 grid-template-columns: repeat(4, 1fr);
180199 .section-content::-webkit-scrollbar-thumb:hover {
181200 background: rgba(55, 53, 47, 0.3);
182201 }
183- .integration -table {
202+ .oauth-association -table {
184203 width: 100%;
185204 border-collapse: collapse;
186205 font-size: 14px;
187206 }
188- .integration -table th {
207+ .oauth-association -table th {
189208 background-color: #f7f6f3;
190209 color: #37352f;
191210 font-weight: 600;
192211 padding: 12px;
193212 text-align: left;
194213 border-bottom: 2px solid rgba(55, 53, 47, 0.09);
195214 }
196- .integration -table td {
215+ .oauth-association -table td {
197216 padding: 12px;
198217 color: #37352f;
199218 border-bottom: 1px solid rgba(55, 53, 47, 0.09);
200219 }
201- .integration -table th:first-child {
220+ .oauth-associatio -table th:first-child {
202221 border-right: 2px solid rgba(55, 53, 47, 0.09);
203222 }
204- .integration -table td:first-child {
223+ .oauth-association -table td:first-child {
205224 font-weight: 500;
206225 border-right: 1px solid rgba(55, 53, 47, 0.09);
207226 }
208- .integration -table td:not(:first-child) {
227+ .oauth-association -table td:not(:first-child) {
209228 text-align: center;
210229 }
211- .integration -table th:not(:first-child) {
230+ .oauth-association -table th:not(:first-child) {
212231 text-align: center;
213232 }
214233 .content-table {
247266 .content-table tbody tr:hover {
248267 background-color: #f7f6f3;
249268 }
269+ .template-header {
270+ position: relative;
271+ cursor: help;
272+ }
273+ .template-header .tooltip-text {
274+ visibility: hidden;
275+ background-color: #37352f;
276+ color: #fff;
277+ text-align: center;
278+ border-radius: 6px;
279+ padding: 8px 12px;
280+ position: absolute;
281+ z-index: 1;
282+ bottom: 125%;
283+ left: 50%;
284+ transform: translateX(-50%);
285+ white-space: nowrap;
286+ font-size: 12px;
287+ font-weight: 400;
288+ opacity: 0;
289+ transition: opacity 0.2s;
290+ }
291+ .template-header .tooltip-text::after {
292+ content: "";
293+ position: absolute;
294+ top: 100%;
295+ left: 50%;
296+ margin-left: -5px;
297+ border-width: 5px;
298+ border-style: solid;
299+ border-color: #37352f transparent transparent transparent;
300+ }
301+ .template-header:hover .tooltip-text {
302+ visibility: visible;
303+ opacity: 1;
304+ }
250305"""
251306
252307def fetch_all_prometheus_metrics (url : str ) -> Dict [str , List [Tuple [Dict [str , str ], float ]]]:
@@ -307,17 +362,21 @@ def get_content_stats(metrics: Dict) -> Dict:
307362
308363 return stats
309364
310- def get_integration_metrics (metrics : Dict ) -> Dict :
311- integrations_count = metrics .get ('integrations_count ' , [])
365+ def get_oauth_association_metrics (metrics : Dict ) -> Dict :
366+ associations_count = metrics .get ('associations_count ' , [])
312367
313368 matrix = {}
314369 templates = set ()
315370 auth_types = set ()
316371
317- for labels , value in integrations_count :
372+ for labels , value in associations_count :
318373 template = labels .get ('integration_template' )
319374 auth_type = labels .get ('integration_auth_type' )
320375
376+ # Collapse "Visitor API Key" into "Viewer" for this dashboard
377+ if auth_type == "Visitor API Key" :
378+ auth_type = "Viewer"
379+
321380 if template and auth_type :
322381 templates .add (template )
323382 auth_types .add (auth_type )
@@ -332,6 +391,20 @@ def get_integration_metrics(metrics: Dict) -> Dict:
332391 'auth_types' : sorted (auth_types )
333392 }
334393
394+ def get_integration_count_by_template (metrics : Dict ) -> Dict [str , int ]:
395+ """Sum integration_count by template."""
396+ integration_count = metrics .get ('integrations_count' , [])
397+ result = {}
398+
399+ for labels , value in integration_count :
400+ template = labels .get ('integration_template' )
401+ if template :
402+ if template not in result :
403+ result [template ] = 0
404+ result [template ] += int (value )
405+
406+ return result
407+
335408def get_system_info (client ):
336409 response = client .get ("server_settings" )
337410 settings = response .json ()
@@ -461,6 +534,36 @@ def create_key_value_list(items_dict):
461534 for label , value in items_dict .items ()
462535 ]
463536
537+ def external_link_icon ():
538+ """Create an SVG external link icon."""
539+ return ui .HTML (
540+ '<svg class="external-link-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
541+ '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'
542+ '<polyline points="15 3 21 3 21 9"/>'
543+ '<line x1="10" y1="14" x2="21" y2="3"/>'
544+ '</svg>'
545+ )
546+
547+ def create_linked_card_title (title : str , url : str ):
548+ """Create a card title with an external link."""
549+ return ui .div (
550+ ui .tags .a (
551+ title ,
552+ external_link_icon (),
553+ href = url ,
554+ target = "_blank"
555+ ),
556+ class_ = "card-title"
557+ )
558+
559+ def get_server_base_url () -> str :
560+ """Get the base URL of the Connect server from environment."""
561+ url = os .environ .get ('CONNECT_SERVER' , '' )
562+ # Remove trailing slash if present
563+ if url .endswith ('/' ):
564+ url = url [:- 1 ]
565+ return url
566+
464567app_ui = ui .page_fluid (
465568 ui .tags .style (DASHBOARD_CSS ),
466569 ui .div (
@@ -481,8 +584,8 @@ def create_key_value_list(items_dict):
481584 class_ = "section-grid-4"
482585 ),
483586 ui .div (
484- ui .div ("OAuth Integration Stats " , class_ = "card-title" ),
485- ui .output_ui ("integration_metrics_table " ),
587+ ui .div ("Content items with OAuth integrations " , class_ = "card-title" ),
588+ ui .output_ui ("oauth_association_metrics_table " ),
486589 class_ = "content-card"
487590 ),
488591 ui .div (
@@ -501,6 +604,7 @@ def create_key_value_list(items_dict):
501604def server (input , output , session ):
502605 metrics = fetch_all_prometheus_metrics ("http://localhost:3232/metrics" )
503606 client = Client ()
607+ server_base_url = get_server_base_url ()
504608
505609 @output
506610 @render .ui
@@ -641,15 +745,26 @@ def sort_key(item):
641745
642746 @output
643747 @render .ui
644- def integration_metrics_table ():
645- integration_data = get_integration_metrics (metrics )
646- matrix = integration_data ['matrix' ]
647- templates = integration_data ['templates' ]
648- auth_types = integration_data ['auth_types' ]
748+ def oauth_association_metrics_table ():
749+ associations_data = get_oauth_association_metrics (metrics )
750+ matrix = associations_data ['matrix' ]
751+ templates = associations_data ['templates' ]
752+ auth_types = associations_data ['auth_types' ]
753+ integration_counts = get_integration_count_by_template (metrics )
754+
755+ def create_template_header (template ):
756+ count = integration_counts .get (template , 0 )
757+ return ui .tags .th (
758+ ui .span (
759+ template ,
760+ ui .span (f"Unique Integrations: { count } " , class_ = "tooltip-text" ),
761+ class_ = "template-header"
762+ )
763+ )
649764
650765 header_row = ui .tags .tr (
651766 ui .tags .th ("" ),
652- * [ui . tags . th (template ) for template in templates ],
767+ * [create_template_header (template ) for template in templates ],
653768 ui .tags .th ("Total" )
654769 )
655770
@@ -668,7 +783,7 @@ def integration_metrics_table():
668783 return ui .tags .table (
669784 ui .tags .thead (header_row ),
670785 ui .tags .tbody (* table_rows ),
671- class_ = "integration -table"
786+ class_ = "oauth-association -table"
672787 )
673788
674789 @output
@@ -693,9 +808,10 @@ def running_schedule_grid():
693808 )
694809 ]
695810
696- col1 = create_content_card (
697- "Currently Running" ,
698- ui .div (* running_items , class_ = "running-items-container" )
811+ col1 = ui .div (
812+ ui .div ("Currently Running" , class_ = "card-title" ),
813+ ui .div (* running_items , class_ = "running-items-container" ),
814+ class_ = "content-card"
699815 )
700816
701817 col2 = ui .div (
@@ -741,11 +857,22 @@ def show_process_breakdown():
741857 process_count = get_process_count_by_tag (metrics )
742858 sorted_process_count = dict (sorted (process_count ['by_tag' ].items (), key = lambda x : x [1 ], reverse = True ))
743859 breakdown_items = create_key_value_list (sorted_process_count )
860+ processes_url = f"{ server_base_url } /connect/#/system/processes"
744861
745862 m = ui .modal (
746863 ui .div (
747864 ui .div ("Process Breakdown by Tag" , style = "font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #37352f;" ),
748865 ui .div (* breakdown_items , class_ = "section-content" ),
866+ ui .div (
867+ ui .tags .a (
868+ "View all processes " ,
869+ external_link_icon (),
870+ href = processes_url ,
871+ target = "_blank" ,
872+ style = "color: #2383e2; text-decoration: none; font-size: 14px; display: inline-flex; align-items: center; gap: 4px;"
873+ ),
874+ style = "margin-top: 16px; padding-top: 12px; border-top: 1px solid rgba(55, 53, 47, 0.09);"
875+ ),
749876 style = "padding: 10px;"
750877 ),
751878 title = None ,
@@ -760,11 +887,22 @@ def show_schedule_breakdown():
760887 schedule_by_status = get_schedule_count_by_status (metrics )
761888 sorted_schedule = dict (sorted (schedule_by_status .items (), key = lambda x : x [1 ], reverse = True ))
762889 breakdown_items = create_key_value_list (sorted_schedule )
890+ schedules_url = f"{ server_base_url } /connect/#/system/schedules"
763891
764892 m = ui .modal (
765893 ui .div (
766894 ui .div ("Schedule by Status" , style = "font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #37352f;" ),
767895 ui .div (* breakdown_items , class_ = "section-content" ),
896+ ui .div (
897+ ui .tags .a (
898+ "View all schedules " ,
899+ external_link_icon (),
900+ href = schedules_url ,
901+ target = "_blank" ,
902+ style = "color: #2383e2; text-decoration: none; font-size: 14px; display: inline-flex; align-items: center; gap: 4px;"
903+ ),
904+ style = "margin-top: 16px; padding-top: 12px; border-top: 1px solid rgba(55, 53, 47, 0.09);"
905+ ),
768906 style = "padding: 10px;"
769907 ),
770908 title = None ,
0 commit comments