@@ -187,8 +187,9 @@ def __init__(self, *args):
187187import uuid
188188from pathlib import Path
189189from typing import Any , Callable , Dict , List , Optional , Tuple
190-
191190import yaml
191+ from cosl import DashboardPath40UID , LZMABase64
192+ from cosl .types import type_convert_stored
192193from ops .charm import (
193194 CharmBase ,
194195 HookEvent ,
@@ -203,12 +204,9 @@ def __init__(self, *args):
203204 EventSource ,
204205 Object ,
205206 ObjectEvents ,
206- StoredDict ,
207- StoredList ,
208207 StoredState ,
209208)
210209from ops .model import Relation
211- from cosl import LZMABase64 , DashboardPath40UID
212210
213211# The unique Charmhub library identifier, never change it
214212LIBID = "c49eb9c7dfef40c7b6235ebd67010a3f"
@@ -219,7 +217,7 @@ def __init__(self, *args):
219217# Increment this PATCH version before using `charmcraft publish-lib` or reset
220218# to 0 if you are raising the major API version
221219
222- LIBPATCH = 44
220+ LIBPATCH = 49
223221
224222PYDEPS = ["cosl >= 0.0.50" ]
225223
@@ -587,9 +585,19 @@ def _convert_dashboard_fields(cls, content: str, inject_dropdowns: bool = True)
587585 datasources [template_value ["name" ]] = template_value ["query" ].lower ()
588586
589587 # Put our own variables in the template
588+ # We only want to inject our own dropdowns IFF they are NOT
589+ # already in the template coming over relation data.
590+ # We'll store all dropdowns in the template from the provider
591+ # in a set. We'll add our own if they are not in this set.
592+ existing_names = {
593+ item .get ("name" )
594+ for item in dict_content ["templating" ]["list" ]
595+ }
596+
590597 for d in template_dropdowns : # type: ignore
591- if d not in dict_content [ "templating" ][ "list" ] :
598+ if d . get ( "name" ) not in existing_names :
592599 dict_content ["templating" ]["list" ].insert (0 , d )
600+ existing_names .add (d .get ("name" ))
593601
594602 dict_content = cls ._replace_template_fields (dict_content , datasources , existing_templates )
595603 return json .dumps (dict_content )
@@ -946,23 +954,33 @@ def _replace_uid(
946954 # If we're running this from within an aggregator (such as grafana agent), then the uid was
947955 # already rendered there, so we do not want to overwrite it with a uid generated from aggregator's info.
948956 # We overwrite the uid only if it's not a valid "Path40" uid.
949- if not DashboardPath40UID .is_valid (original_uid := dashboard_dict .get ("uid" , "" )):
957+ original_uid = dashboard_dict .get ("uid" , "" )
958+
959+ if DashboardPath40UID .is_valid (original_uid ):
960+ logger .debug (
961+ "Processed dashboard '%s': kept original uid '%s'" , dashboard_path , original_uid
962+ )
963+ return
964+
965+ try :
950966 rel_path = str (
951967 dashboard_path .relative_to (charm_dir )
952968 if dashboard_path .is_absolute ()
953969 else dashboard_path
954970 )
955- dashboard_dict ["uid" ] = DashboardPath40UID .generate (charm_name , rel_path )
956- logger .debug (
957- "Processed dashboard '%s': replaced original uid '%s' with '%s'" ,
958- dashboard_path ,
959- original_uid ,
960- dashboard_dict ["uid" ],
961- )
971+ except ValueError :
972+ uid = DashboardPath40UID .generate (charm_name , str (dashboard_path ))
962973 else :
963- logger .debug (
964- "Processed dashboard '%s': kept original uid '%s'" , dashboard_path , original_uid
965- )
974+ uid = DashboardPath40UID .generate (charm_name , rel_path )
975+
976+
977+ logger .debug (
978+ "Processed dashboard '%s': replaced original uid '%s' with '%s'" ,
979+ dashboard_path ,
980+ original_uid ,
981+ uid ,
982+ )
983+ dashboard_dict ["uid" ] = uid
966984
967985 @classmethod
968986 def _add_tags (cls , dashboard_dict : dict , charm_name : str ):
@@ -995,7 +1013,7 @@ def _is_dashboard(p: Path) -> bool:
9951013
9961014 dashboard_templates = {}
9971015
998- for path in filter (_is_dashboard , Path (dashboards_path ).glob ("*" )):
1016+ for path in filter (_is_dashboard , Path (dashboards_path ).glob ("**/* " )):
9991017 try :
10001018 dashboard_dict = json .loads (path .read_bytes ())
10011019 except json .JSONDecodeError as e :
@@ -1027,18 +1045,6 @@ def _is_dashboard(p: Path) -> bool:
10271045 return dashboard_templates
10281046
10291047
1030- def _type_convert_stored (obj ):
1031- """Convert Stored* to their appropriate types, recursively."""
1032- if isinstance (obj , StoredList ):
1033- return list (map (_type_convert_stored , obj ))
1034- if isinstance (obj , StoredDict ):
1035- rdict = {} # type: Dict[Any, Any]
1036- for k in obj .keys ():
1037- rdict [k ] = _type_convert_stored (obj [k ])
1038- return rdict
1039- return obj
1040-
1041-
10421048class GrafanaDashboardsChanged (EventBase ):
10431049 """Event emitted when Grafana dashboards change."""
10441050
@@ -1346,7 +1352,7 @@ def _upset_dashboards_on_relation(self, relation: Relation) -> None:
13461352 # It's completely ridiculous to add a UUID, but if we don't have some
13471353 # pseudo-random value, this never makes it across 'juju set-state'
13481354 stored_data = {
1349- "templates" : _type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
1355+ "templates" : type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
13501356 "uuid" : str (uuid .uuid4 ()),
13511357 }
13521358
@@ -1612,7 +1618,7 @@ def _render_dashboards_and_signal_changed(self, relation: Relation) -> bool: #
16121618 stored_data = rendered_dashboards
16131619 currently_stored_data = self ._get_stored_dashboards (relation .id )
16141620
1615- coerced_data = _type_convert_stored (currently_stored_data ) if currently_stored_data else {}
1621+ coerced_data = type_convert_stored (currently_stored_data ) if currently_stored_data else {}
16161622
16171623 if not coerced_data == stored_data :
16181624 stored_dashboards = self .get_peer_data ("dashboards" )
@@ -1639,29 +1645,60 @@ def _remove_all_dashboards_for_relation(self, relation: Relation) -> None:
16391645 self .on .dashboards_changed .emit () # pyright: ignore
16401646
16411647 def _to_external_object (self , relation_id , dashboard ):
1648+ decompressed = LZMABase64 .decompress (dashboard ["content" ])
1649+ as_dict = json .loads (decompressed )
1650+
1651+ dashboard_title = as_dict .get ("title" , "" )
1652+ dashboard_uid = as_dict .get ("uid" , "" )
1653+
1654+ try :
1655+ dashboard_version = int (as_dict ["version" ])
1656+ except (KeyError , ValueError ):
1657+ logger .warning ("Dashboard '%s' (uid '%s') is missing a '.version' field or is invalid (must be integer); using '0' as fallback" , dashboard_title , dashboard_uid )
1658+ dashboard_version = 0
1659+
16421660 return {
16431661 "id" : dashboard ["original_id" ],
16441662 "relation_id" : relation_id ,
16451663 "charm" : dashboard ["template" ]["charm" ],
1646- "content" : LZMABase64 .decompress (dashboard ["content" ]),
1664+ "content" : decompressed ,
1665+ "dashboard_uid" : dashboard_uid ,
1666+ "dashboard_version" : dashboard_version ,
1667+ "dashboard_title" : dashboard_title ,
16471668 }
16481669
16491670 @property
16501671 def dashboards (self ) -> List [Dict ]:
16511672 """Get a list of known dashboards across all instances of the monitored relation.
16521673
1674+ Filters out dashboards with the same uid, keeping only the one with the highest version.
1675+ When more than one dashboard have the same uid and version, keep the first one when
1676+ sorted by (relation_id, content) in reverse lexicographic order (highest relid first).
1677+
16531678 Returns: a list of known dashboards. The JSON of each of the dashboards is available
16541679 in the `content` field of the corresponding `dict`.
16551680 """
1656- dashboards = []
1681+ d : Dict [ str , dict ] = {}
16571682
16581683 for _ , (relation_id , dashboards_for_relation ) in enumerate (
16591684 self .get_peer_data ("dashboards" ).items ()
16601685 ):
16611686 for dashboard in dashboards_for_relation :
1662- dashboards .append (self ._to_external_object (relation_id , dashboard ))
1687+ obj = self ._to_external_object (relation_id , dashboard )
1688+
1689+ key = obj .get ("dashboard_uid" )
1690+ if key is None or str (key ).strip () == "" :
1691+ # At this point, we assume that a `.uid` is present so we do not render a fallback identifier here. Instead, we omit it.
1692+ logger .error ("dashboard '%s' from relation id '%s' is missing a '.uid' field; omitted" , obj ["dashboard_title" ], obj ["relation_id" ])
1693+ continue
1694+
1695+ if key in d :
1696+ d [key ] = max (d [key ], obj , key = lambda o : (o ["dashboard_version" ], o ["relation_id" ], o ["content" ]))
1697+ logger .warning ("deduplicate dashboard '%s' (uid '%s') - kept version '%s' from relation id '%s'" , d [key ]["dashboard_title" ], d [key ]["dashboard_uid" ], d [key ]["dashboard_version" ], d [key ]["relation_id" ])
1698+ else :
1699+ d [key ] = obj
16631700
1664- return dashboards
1701+ return list ( d . values ())
16651702
16661703 def _get_stored_dashboards (self , relation_id : int ) -> list :
16671704 """Pull stored dashboards out of the peer data bucket."""
@@ -1796,7 +1833,7 @@ def _update_remote_grafana(self, _: Optional[RelationEvent] = None) -> None:
17961833 """Push dashboards to the downstream Grafana relation."""
17971834 # It's still ridiculous to add a UUID here, but needed
17981835 stored_data = {
1799- "templates" : _type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
1836+ "templates" : type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
18001837 "uuid" : str (uuid .uuid4 ()),
18011838 }
18021839
@@ -1806,7 +1843,7 @@ def _update_remote_grafana(self, _: Optional[RelationEvent] = None) -> None:
18061843
18071844 def remove_dashboards (self , event : RelationBrokenEvent ) -> None :
18081845 """Remove a dashboard if the relation is broken."""
1809- app_ids = _type_convert_stored (self ._stored .id_mappings .get (event .app .name , "" )) # type: ignore
1846+ app_ids = type_convert_stored (self ._stored .id_mappings .get (event .app .name , "" )) # type: ignore
18101847
18111848 if not app_ids :
18121849 logger .info ("Could not look up stored dashboards for %s" , event .app .name ) # type: ignore
@@ -1817,7 +1854,7 @@ def remove_dashboards(self, event: RelationBrokenEvent) -> None:
18171854 del self ._stored .dashboard_templates [id ] # type: ignore
18181855
18191856 stored_data = {
1820- "templates" : _type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
1857+ "templates" : type_convert_stored (self ._stored .dashboard_templates ), # pyright: ignore
18211858 "uuid" : str (uuid .uuid4 ()),
18221859 }
18231860
0 commit comments