diff --git a/stats/config/charts.json b/stats/config/charts.json index a2b3c5308..0955ef1a7 100644 --- a/stats/config/charts.json +++ b/stats/config/charts.json @@ -69,6 +69,10 @@ "title": "Number of deployed contracts today", "description": "Number of deployed contracts today" }, + "new_contracts_24h": { + "title": "Contracts indexed within last 24h", + "description": "(24h)" + }, "total_contracts": { "title": "Total contracts", "description": "Number of contracts" @@ -77,6 +81,10 @@ "title": "Number of verified contracts today", "description": "Number of contracts verified today" }, + "new_verified_contracts_24h": { + "title": "Number of verified contracts within last 24h", + "description": "(24h)" + }, "total_verified_contracts": { "title": "Total verified contracts", "description": "Number of verified contracts" diff --git a/stats/config/layout.json b/stats/config/layout.json index 62263bd51..82ae90734 100644 --- a/stats/config/layout.json +++ b/stats/config/layout.json @@ -4,6 +4,8 @@ "completed_txns", "last_new_contracts", "last_new_verified_contracts", + "new_contracts_24h", + "new_verified_contracts_24h", "total_accounts", "total_addresses", "total_blocks", diff --git a/stats/config/update_groups.json b/stats/config/update_groups.json index 02299df0b..39f060edf 100644 --- a/stats/config/update_groups.json +++ b/stats/config/update_groups.json @@ -33,12 +33,13 @@ "new_blocks_group": "0 0 8 * * * *", "txns_fee_group": "0 0 7 * * * *", "txns_success_rate_group": "0 0 19 * * * *", - "new_accounts_group": "0 0 5 * * * *", + "new_accounts_group": "0 0 4 * * * *", "new_contracts_group": "0 20 */3 * * * *", "new_txns_group": "0 10 */3 * * * *", "new_verified_contracts_group": "0 30 */3 * * * *", "native_coin_holders_growth_group": "0 0 7,17,22 * * * *", "new_native_coin_transfers_group": "0 0 3,13 * * * *", - "txns_stats_24h_group": "0 30 * * * * *" + "txns_stats_24h_group": "0 30 * * * * *", + "verified_contracts_page_group": "0 15,45 * * * * *" } } \ No newline at end of file diff --git a/stats/config/utils/README.md b/stats/config/utils/README.md deleted file mode 100644 index 8a8b27c9f..000000000 --- a/stats/config/utils/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Config utilities - -## `find_free_timeslot.py` - -It's a tool to roughly visualize the busyness of update schedule to find a timeslot for some new update group. - -### Usage - -1. Install tkinter (e.g. `apt-get install python3-tk` or `brew install python-tk`) -2. Install other dependencies from `requirements.txt`: `pip install -r requirements.txt` -3. Run `python find_free_timeslot.py` diff --git a/stats/config/utils/free_timeslots/README.md b/stats/config/utils/free_timeslots/README.md new file mode 100644 index 000000000..83759d706 --- /dev/null +++ b/stats/config/utils/free_timeslots/README.md @@ -0,0 +1,32 @@ +# Update timeslots visualization + +## Preparations + +1. Install tkinter (e.g. `apt-get install python3-tk` or `brew install python-tk`) for `find_free_timeslot.py` GUI +2. Install other dependencies from `requirements.txt`: `pip install -r requirements.txt` + +## `find_free_timeslot.py` + +It's a tool to roughly visualize the busyness of update schedule to find a timeslot for some new update group. + +### Usage + +Just run `python find_free_timeslot.py` and use GUI to find less crowded timeslots. +You can regenerate durations config for more accurate representation. +See below for details + +## Durations config + +This is a script to generate a config for an accurate visualization within `find_free_timeslot` script. + +### Usage + +1. Get data fetch time statistics (e.g. from grafana) (example: `data.csv.example`). In our case, you can: + - Open "Microservices > Stats" dashboard + - Find "Average data fetch time" + - [Three dots] > [Inspect] > [Data] + - [Data options] > [Show data frame] > [Series joined by time] + - [Formatted data] = off + - [Download CSV] +2. Run the script (preferably from this folder, to correctly use default parameters) (see `--help` for details) +3. Enjoy newly generated `durations.json` after running `find_free_timeslot` script. \ No newline at end of file diff --git a/stats/config/utils/free_timeslots/durations/.gitignore b/stats/config/utils/free_timeslots/durations/.gitignore new file mode 100644 index 000000000..16f2dc5fa --- /dev/null +++ b/stats/config/utils/free_timeslots/durations/.gitignore @@ -0,0 +1 @@ +*.csv \ No newline at end of file diff --git a/stats/config/utils/free_timeslots/durations/data.csv.example b/stats/config/utils/free_timeslots/durations/data.csv.example new file mode 100644 index 000000000..e6680129c --- /dev/null +++ b/stats/config/utils/free_timeslots/durations/data.csv.example @@ -0,0 +1,2 @@ +"Time","accountsGrowth_DAY","accountsGrowth_MONTH","accountsGrowth_WEEK","accountsGrowth_YEAR","activeAccounts_DAY","averageBlockRewards_DAY","averageBlockRewards_MONTH","averageBlockRewards_WEEK","averageBlockRewards_YEAR","averageBlockSize_DAY","averageBlockSize_MONTH","averageBlockSize_WEEK","averageBlockSize_YEAR","averageBlockTime_DAY","averageGasLimit_DAY","averageGasLimit_MONTH","averageGasLimit_WEEK","averageGasLimit_YEAR","averageGasPrice_DAY","averageGasPrice_MONTH","averageGasPrice_WEEK","averageGasPrice_YEAR","averageTxnFee_DAY","averageTxnFee_MONTH","averageTxnFee_WEEK","averageTxnFee_YEAR","completedTxns_DAY","gasUsedGrowth_DAY","gasUsedGrowth_MONTH","gasUsedGrowth_WEEK","gasUsedGrowth_YEAR","lastNewVerifiedContracts_DAY","newAccounts_DAY","newAccounts_MONTH","newAccounts_WEEK","newAccounts_YEAR","newBlockRewards_DAY","newBlocks_DAY","newBlocks_MONTH","newBlocks_WEEK","newBlocks_YEAR","newNativeCoinTransfers_DAY","newNativeCoinTransfers_MONTH","newNativeCoinTransfers_WEEK","newNativeCoinTransfers_YEAR","newTxns_DAY","newTxns_MONTH","newTxns_WEEK","newTxns_YEAR","newVerifiedContracts_DAY","newVerifiedContracts_MONTH","newVerifiedContracts_WEEK","newVerifiedContracts_YEAR","totalAccounts_DAY","totalAddresses_DAY","totalBlocks_DAY","totalNativeCoinTransfers_DAY","totalTokens_DAY","totalTxns_DAY","totalVerifiedContracts_DAY","txnsFee_DAY","txnsFee_MONTH","txnsFee_WEEK","txnsFee_YEAR","txnsGrowth_DAY","txnsGrowth_MONTH","txnsGrowth_WEEK","txnsGrowth_YEAR","txnsSuccessRate_DAY","txnsSuccessRate_MONTH","txnsSuccessRate_WEEK","txnsSuccessRate_YEAR","verifiedContractsGrowth_DAY","verifiedContractsGrowth_MONTH","verifiedContractsGrowth_WEEK","verifiedContractsGrowth_YEAR" +1735022010000,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34,12.34 \ No newline at end of file diff --git a/stats/config/utils/free_timeslots/durations/durations.json b/stats/config/utils/free_timeslots/durations/durations.json new file mode 100644 index 000000000..76426df1e --- /dev/null +++ b/stats/config/utils/free_timeslots/durations/durations.json @@ -0,0 +1,42 @@ +{ + "active_accounts_group": 1, + "average_block_time_group": 1, + "completed_txns_group": 19, + "pending_txns_group": 1, + "total_addresses_group": 3, + "total_blocks_group": 1, + "total_txns_group": 1, + "total_operational_txns_group": 1, + "total_tokens_group": 1, + "yesterday_txns_group": 1, + "active_recurring_accounts_daily_recurrence60_days_group": 1, + "active_recurring_accounts_monthly_recurrence60_days_group": 1, + "active_recurring_accounts_weekly_recurrence60_days_group": 1, + "active_recurring_accounts_yearly_recurrence60_days_group": 1, + "active_recurring_accounts_daily_recurrence90_days_group": 1, + "active_recurring_accounts_monthly_recurrence90_days_group": 1, + "active_recurring_accounts_weekly_recurrence90_days_group": 1, + "active_recurring_accounts_yearly_recurrence90_days_group": 1, + "active_recurring_accounts_daily_recurrence120_days_group": 1, + "active_recurring_accounts_monthly_recurrence120_days_group": 1, + "active_recurring_accounts_weekly_recurrence120_days_group": 1, + "active_recurring_accounts_yearly_recurrence120_days_group": 1, + "new_txns_window_group": 1, + "average_block_rewards_group": 1, + "average_block_size_group": 1, + "average_gas_limit_group": 1, + "average_gas_price_group": 1, + "average_txn_fee_group": 1, + "gas_used_growth_group": 1, + "native_coin_supply_group": 1, + "new_blocks_group": 1, + "txns_fee_group": 1, + "txns_success_rate_group": 2, + "txns_stats24h_group": 1, + "new_accounts_group": 102, + "new_contracts_group": 1, + "new_txns_group": 1, + "new_verified_contracts_group": 1, + "native_coin_holders_growth_group": 1, + "new_native_coin_transfers_group": 1 +} \ No newline at end of file diff --git a/stats/config/utils/find_free_timeslot.py b/stats/config/utils/free_timeslots/find_free_timeslot.py similarity index 54% rename from stats/config/utils/find_free_timeslot.py rename to stats/config/utils/free_timeslots/find_free_timeslot.py index e6f777d54..10dee8278 100644 --- a/stats/config/utils/find_free_timeslot.py +++ b/stats/config/utils/free_timeslots/find_free_timeslot.py @@ -7,27 +7,48 @@ import os from tkcalendar import Calendar from typing import Dict, List, Tuple +from enum import Enum + + +class DurationMenu(Enum): + MANUAL = "manual" + CONFIG = "config" + class CronVisualizerGUI: def __init__(self, root): self.root = root self.root.title("Cron Schedule Visualizer") self.root.geometry("1200x800") - + self.schedules = {} self.canvas_width = 1000 self.canvas_height = 200 self.hour_width = self.canvas_width // 24 self.selected_date = datetime.now() + self.default_duration = 20 # Duration in minutes - + self.task_durations = {} # Will store durations from config + self.use_config_durations = tk.BooleanVar( + value=False + ) # Toggle for duration source + + # Load durations config if exists + durations_path = "durations/durations.json" + if os.path.exists(durations_path): + try: + with open(durations_path, "r") as f: + self.task_durations = json.load(f) + except Exception as e: + print(f"Failed to load durations file: {str(e)}") + # Add default path - default_path = "../update_groups.json" + default_path = "../../update_groups.json" if os.path.exists(default_path): try: - with open(default_path, 'r') as f: + with open(default_path, "r") as f: data = json.load(f) - self.schedules = data.get('schedules', {}) + self.schedules = data.get("schedules", {}) except Exception as e: print(f"Failed to load default file: {str(e)}") @@ -36,61 +57,95 @@ def __init__(self, root): if self.schedules: self.update_visualization() self.update_schedule_list() - + def setup_gui(self): # Top frame for file selection and controls top_frame = ttk.Frame(self.root, padding="10") top_frame.pack(fill=tk.X) - - ttk.Button(top_frame, text="Load JSON File", command=self.load_json).pack(side=tk.LEFT, padx=5) - + + left_top_frame = ttk.Frame(top_frame, padding="10") + left_top_frame.pack(side=tk.LEFT) + next_left_top_frame = ttk.Frame(top_frame, padding="10") + next_left_top_frame.pack(side=tk.LEFT) + + ttk.Button(left_top_frame, text="Load JSON File", command=self.load_json).pack( + side=tk.TOP, fill="x" + ) + ttk.Button( + left_top_frame, text="Update", command=self.update_visualization + ).pack(side=tk.TOP, fill="x") + self.ignore_days_var = tk.BooleanVar() - ttk.Checkbutton(top_frame, text="Ignore day parameters", - variable=self.ignore_days_var, - command=self.update_visualization).pack(side=tk.LEFT, padx=5) - + ttk.Checkbutton( + left_top_frame, + text="Ignore day parameters", + variable=self.ignore_days_var, + command=self.update_visualization, + ).pack(side=tk.TOP) + # Duration control - ttk.Label(top_frame, text="Duration (minutes):").pack(side=tk.LEFT, padx=5) - self.duration_var = tk.StringVar(value=str(self.default_duration)) - duration_entry = ttk.Entry(top_frame, textvariable=self.duration_var, width=5) - duration_entry.pack(side=tk.LEFT, padx=5) - duration_entry.bind('', lambda e: self.update_visualization()) - ttk.Button(top_frame, text="Update", command=self.update_visualization).pack(side=tk.LEFT, padx=5) - + radiobutton_frame_1 = ttk.Frame(next_left_top_frame) + radiobutton_frame_1.pack(side=tk.TOP) + self.duration_choice = tk.StringVar(value=DurationMenu.CONFIG.value) + ttk.Radiobutton( + radiobutton_frame_1, + text="Fixed duration (minutes):", + variable=self.duration_choice, + value=DurationMenu.MANUAL.value, + command=self.update_visualization, + ).pack(side=tk.LEFT) + self.manual_duration_var = tk.StringVar(value=str(self.default_duration)) + duration_entry = ttk.Entry( + radiobutton_frame_1, textvariable=self.manual_duration_var, width=5 + ) + duration_entry.pack(side=tk.LEFT) + duration_entry.bind("", lambda e: self.update_visualization()) + + ttk.Radiobutton( + next_left_top_frame, + text="Per-task durations from config", + variable=self.duration_choice, + value=DurationMenu.CONFIG.value, + command=self.update_visualization, + ).pack(side=tk.TOP, fill="x") + # Calendar widget - calendar_frame = ttk.Frame(self.root, padding="10") - calendar_frame.pack(fill=tk.X) - - self.calendar = Calendar(calendar_frame, selectmode='day', - year=self.selected_date.year, - month=self.selected_date.month, - day=self.selected_date.day) - self.calendar.pack(side=tk.LEFT) - self.calendar.bind('<>', self.on_date_select) - + calendar_frame = ttk.Frame(top_frame) + calendar_frame.pack(side=tk.RIGHT) + + self.calendar = Calendar( + calendar_frame, + selectmode="day", + year=self.selected_date.year, + month=self.selected_date.month, + day=self.selected_date.day, + ) + self.calendar.pack(side=tk.RIGHT) + self.calendar.bind("<>", self.on_date_select) + # Timeline canvas canvas_frame = ttk.Frame(self.root, padding="10") canvas_frame.pack(fill=tk.BOTH, expand=True) - - self.canvas = tk.Canvas(canvas_frame, - width=self.canvas_width, - height=self.canvas_height, - bg='white') + + self.canvas = tk.Canvas( + canvas_frame, width=self.canvas_width, height=self.canvas_height, bg="white" + ) self.canvas.pack(fill=tk.BOTH, expand=True) - + # Bind mouse motion for hover effect - self.canvas.bind('', self.on_hover) - + self.canvas.bind("", self.on_hover) + # Schedule list list_frame = ttk.Frame(self.root, padding="10") list_frame.pack(fill=tk.BOTH, expand=True) - - self.schedule_list = ttk.Treeview(list_frame, columns=('Schedule', 'Times'), - show='headings') - self.schedule_list.heading('Schedule', text='Schedule Name') - self.schedule_list.heading('Times', text='Execution Times') + + self.schedule_list = ttk.Treeview( + list_frame, columns=("Schedule", "Times"), show="headings" + ) + self.schedule_list.heading("Schedule", text="Schedule Name") + self.schedule_list.heading("Times", text="Execution Times") self.schedule_list.pack(fill=tk.BOTH, expand=True) - + # Status bar self.status_var = tk.StringVar() status_bar = ttk.Label(self.root, textvariable=self.status_var) @@ -100,162 +155,190 @@ def convert_7field_to_5field(self, cron_str: str) -> str: """Convert 7-field cron (with seconds and years) to 5-field format.""" fields = cron_str.split() if len(fields) == 7: - return ' '.join(fields[1:-1]) + return " ".join(fields[1:-1]) return cron_str - + def load_json(self): file_path = filedialog.askopenfilename( - filetypes=[("JSON files", "*.json"), ("All files", "*.*")]) + filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + ) if not file_path: return - + try: - with open(file_path, 'r') as f: + with open(file_path, "r") as f: data = json.load(f) - self.schedules = data.get('schedules', {}) + self.schedules = data.get("schedules", {}) self.update_visualization() self.update_schedule_list() except Exception as e: messagebox.showerror("Error", f"Failed to load file: {str(e)}") - + def get_color(self, value: int, max_value: int) -> str: """Generate color based on value intensity.""" if max_value == 0: return "#FFFFFF" - + # Convert from HSV to RGB (using red hue, varying saturation) hue = 0 # Red saturation = min(value / max_value, 1.0) value = 1.0 # Brightness rgb = colorsys.hsv_to_rgb(hue, saturation, value) - + return f"#{int(rgb[0]*255):02x}{int(rgb[1]*255):02x}{int(rgb[2]*255):02x}" - - def parse_cron_schedule(self, schedule: str, target_date: datetime) -> List[datetime]: + + def parse_cron_schedule( + self, schedule: str, target_date: datetime + ) -> List[datetime]: """Parse cron schedule and return list of times it occurs in 24 hours.""" if self.ignore_days_var.get(): parts = schedule.split() - parts[3:] = ['*'] * len(parts[3:]) - schedule = ' '.join(parts) - + parts[3:] = ["*"] * len(parts[3:]) + schedule = " ".join(parts) + schedule = self.convert_7field_to_5field(schedule) base = target_date.replace(hour=0, minute=0, second=0, microsecond=0) next_day = base + timedelta(days=1) - + try: cron = croniter(schedule, base) times = [] next_time = cron.get_next(datetime) - + while next_time < next_day: times.append(next_time) next_time = cron.get_next(datetime) - + return times except ValueError: return [] - + def get_task_overlaps(self) -> List[List[str]]: """Calculate overlapping tasks for each minute of the day.""" + timeline = [[] for _ in range(24 * 60)] + + # Get manual duration if not using config try: - duration = int(self.duration_var.get()) + manual_duration = int(self.manual_duration_var.get()) except ValueError: - duration = self.default_duration - - # Initialize timeline with empty lists for each minute - timeline = [[] for _ in range(24 * 60)] - + manual_duration = self.default_duration + # For each schedule, add its task duration to the timeline for name, schedule in self.schedules.items(): start_times = self.parse_cron_schedule(schedule, self.selected_date) - + + # Determine duration for this task + if self.duration_choice.get() == DurationMenu.CONFIG.value: + duration = self.task_durations.get(name, manual_duration) + else: + duration = manual_duration + for start_time in start_times: start_minute = start_time.hour * 60 + start_time.minute - + # Add the task name to each minute it runs - for minute in range(start_minute, min(start_minute + duration, 24 * 60)): + for minute in range( + start_minute, min(start_minute + duration, 24 * 60) + ): timeline[minute].append(name) - + return timeline - + def update_visualization(self): - self.canvas.delete('all') - + self.canvas.delete("all") + # Draw hour lines and labels for hour in range(25): x = hour * self.hour_width - self.canvas.create_line(x, 0, x, self.canvas_height, fill='gray') + self.canvas.create_line(x, 0, x, self.canvas_height, fill="gray") if hour < 24: - self.canvas.create_text(x + self.hour_width/2, self.canvas_height - 20, - text=f"{hour:02d}:00") - + self.canvas.create_text( + x + self.hour_width / 2, + self.canvas_height - 20, + text=f"{hour:02d}:00", + ) + # Get timeline with overlaps timeline = self.get_task_overlaps() max_overlaps = max(len(tasks) for tasks in timeline) - + # Draw visualization for minute in range(24 * 60): hour = minute // 60 minute_in_hour = minute % 60 - + x = hour * self.hour_width + (minute_in_hour * self.hour_width / 60) count = len(timeline[minute]) - + if count > 0: color = self.get_color(count, max_overlaps) x2 = x + self.hour_width / 60 - + self.canvas.create_rectangle( - x, 20, - x2, self.canvas_height - 40, - fill=color, outline='', - tags=('time_slot', f'minute_{minute}', - f'count_{count}', - f'tasks_{"/".join(timeline[minute])}') # Change separator to '/' + x, + 20, + x2, + self.canvas_height - 40, + fill=color, + outline="", + tags=( + "time_slot", + f"minute_{minute}", + f"count_{count}", + f'tasks_{"/".join(timeline[minute])}', + ), # Change separator to '/' ) - + self.status_var.set(f"Maximum concurrent tasks: {max_overlaps}") - + def update_schedule_list(self): self.schedule_list.delete(*self.schedule_list.get_children()) for name, schedule in self.schedules.items(): times = self.parse_cron_schedule(schedule, self.selected_date) if times or self.ignore_days_var.get(): - time_str = ', '.join(t.strftime('%H:%M') for t in times) - self.schedule_list.insert('', 'end', values=(name, time_str)) - + time_str = ", ".join(t.strftime("%H:%M") for t in times) + self.schedule_list.insert("", "end", values=(name, time_str)) + def on_date_select(self, event=None): date = self.calendar.get_date() - self.selected_date = datetime.strptime(date, '%m/%d/%y') + self.selected_date = datetime.strptime(date, "%m/%d/%y") self.update_visualization() self.update_schedule_list() - + def on_hover(self, event): x, y = event.x, event.y - + if 20 <= y <= self.canvas_height - 40: hour = int(x // self.hour_width) minute_in_hour = int((x % self.hour_width) / (self.hour_width / 60)) minute_index = hour * 60 + minute_in_hour - + if 0 <= minute_index < 24 * 60: time_str = f"{hour:02d}:{minute_in_hour:02d}" - items = self.canvas.find_overlapping(x-1, 20, x+1, self.canvas_height-40) + items = self.canvas.find_overlapping( + x - 1, 20, x + 1, self.canvas_height - 40 + ) if items: for item in items: tags = self.canvas.gettags(item) # Fix 1: Check if we have a tasks tag before accessing index 3 - tasks_tag = next((tag for tag in tags if tag.startswith('tasks_')), None) + tasks_tag = next( + (tag for tag in tags if tag.startswith("tasks_")), None + ) if tasks_tag: - tasks = tasks_tag[6:].split('/') # Fix 2: Change separator to '/' + tasks = tasks_tag[6:].split( + "/" + ) # Fix 2: Change separator to '/' count = len(tasks) - task_list = ', '.join(tasks) + task_list = ", ".join(tasks) self.status_var.set( - f"Time: {time_str} - {count} concurrent tasks: {task_list}") + f"Time: {time_str} - {count} concurrent tasks: {task_list}" + ) break else: self.status_var.set(f"Time: {time_str} - No tasks") + if __name__ == "__main__": root = tk.Tk() app = CronVisualizerGUI(root) diff --git a/stats/config/utils/free_timeslots/generate_durations.py b/stats/config/utils/free_timeslots/generate_durations.py new file mode 100644 index 000000000..53e4cdd67 --- /dev/null +++ b/stats/config/utils/free_timeslots/generate_durations.py @@ -0,0 +1,203 @@ +import pandas as pd +import re +import json +from typing import Dict, List, Set, Optional +import typer +from pathlib import Path + + +def convert_camel_to_snake(name: str) -> str: + """Convert camelCase to snake_case.""" + name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name) + return name.lower() + + +def normalize_period(period: str) -> str: + """Normalize period names (WEEK -> Weekly, etc).""" + period_map = {"WEEK": "Weekly", "MONTH": "Monthly", "YEAR": "Yearly", "DAY": ""} + return period_map.get(period, period) + + +def parse_rust_groups(rust_file_path: str) -> Dict[str, List[str]]: + """Parse Rust file to extract update groups and their charts.""" + with open(rust_file_path, "r") as f: + content = f.read() + + # Extract singleton groups + singleton_pattern = r"singleton_groups!\(([\s\S]*?)\);" + singleton_match = re.search(singleton_pattern, content) + + groups = {} + if singleton_match: + # Extract individual chart names, skipping comments + charts = re.findall( + r"^\s*([A-Za-z0-9]+),?\s*(?://.*)?$", singleton_match.group(1), re.MULTILINE + ) + + # Create group names and entries for singleton groups + for chart in charts: + group_name = f"{chart}Group" + groups[group_name] = [chart] + + # Extract complex groups + group_pattern = ( + r"construct_update_group!\((\w+)\s*\{[\s\S]*?charts:\s*\[([\s\S]*?)\]" + ) + complex_groups = re.finditer(group_pattern, content) + + for match in complex_groups: + group_name = match.group(1) + # Extract chart names, handling possible comments + charts = re.findall(r"([A-Za-z0-9]+),", match.group(2)) + if charts: + groups[group_name] = charts + + return groups + + +def process_durations( + csv_path: Path, rust_path: Path, output_path: Path, verbose: bool = False +) -> Dict[str, int]: + """Process duration data and create config file.""" + if verbose: + print(f"Reading duration data from {csv_path}") + + # Read first row of CSV + df = pd.read_csv(csv_path, nrows=1) + + # Get duration columns (skip 'Time' column) + duration_cols = [col for col in df.columns if col != "Time"] + + if verbose: + print(f"Found {len(duration_cols)} duration columns") + + # Create mapping of chart names to durations + chart_durations = {} + for col in duration_cols: + # Split column name into chart and period + parts = col.split("_") + if len(parts) == 2: + chart_name, period = parts + + # Convert to camelCase and normalize period if present + camel_chart = "".join( + word.capitalize() + for word in convert_camel_to_snake(chart_name).split("_") + ) + if period in ["WEEK", "MONTH", "YEAR", "DAY"]: + camel_chart += normalize_period(period) + + # Store duration (convert to milliseconds and round to nearest minute) + duration_mins = round( + float(df[col].iloc[0]) / 60 + ) # assuming duration is in seconds + chart_durations[camel_chart] = duration_mins + + if verbose: + print(f"Processed chart {camel_chart}: {duration_mins} minutes") + + if verbose: + print(f"\nParsing group definitions from {rust_path}") + + # Parse group definitions + groups = parse_rust_groups(rust_path) + + if verbose: + print(f"Found {len(groups)} update groups") + + # Calculate group durations + group_durations = {} + for group_name, charts in groups.items(): + total_duration = 0 + missing_charts = [] + matched_charts = [] + + for chart in charts: + if chart in chart_durations: + total_duration += chart_durations[chart] + matched_charts.append(chart) + else: + missing_charts.append(chart) + + # Convert group name to snake_case for consistency with visualizer + snake_group = convert_camel_to_snake(group_name) + group_durations[snake_group] = max( + 1, total_duration + ) # ensure at least 1 minute + + if verbose: + print(f"\nGroup: {snake_group}") + print(f"Total duration: {group_durations[snake_group]} minutes") + if missing_charts: + print( + f"Warning: Missing duration data for charts: {', '.join(missing_charts)}" + ) + if matched_charts: + print( + f"Duration data found for charts: {', '.join(matched_charts)}" + ) + else: + print(f"No charts in the group had duration data") + + # Save to JSON file + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + json.dump(group_durations, f, indent=2) + + if verbose: + print(f"\nSaved durations configuration to {output_path}") + + return group_durations + + +def main( + csv_path: Path = typer.Argument( + ..., + help="Path to CSV file with duration data", + exists=True, + dir_okay=False, + readable=True, + ), + rust_path: Path = typer.Option( + Path("../../../stats/src/update_groups.rs"), + help="Path to Rust file with group definitions", + exists=True, + dir_okay=False, + readable=True, + ), + output_path: Path = typer.Option( + Path("durations/durations.json"), + "--output", + "-o", + help="Path for output JSON file", + writable=True, + ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Enable verbose output" + ), + print_durations: bool = typer.Option( + False, "--print", "-p", help="Print calculated durations" + ), +): + """ + Process update durations from CSV data and group definitions from Rust code. + + This tool reads duration data from a CSV file and group definitions from a Rust source file, + calculates total durations for each update group, and saves the results to a JSON file + that can be used by the visualization tool. + """ + try: + durations = process_durations(csv_path, rust_path, output_path, verbose) + + if print_durations: + print("\nCalculated durations:") + for group, duration in sorted(durations.items()): + print(f"{group}: {duration} minutes") + + except Exception as e: + typer.echo(f"Error: {str(e)}", err=True) + raise typer.Exit(code=1) + + +if __name__ == "__main__": + typer.run(main) diff --git a/stats/config/utils/free_timeslots/requirements.txt b/stats/config/utils/free_timeslots/requirements.txt new file mode 100644 index 000000000..3be8d7b57 --- /dev/null +++ b/stats/config/utils/free_timeslots/requirements.txt @@ -0,0 +1,3 @@ +tkcalendar>=1.6.1 +croniter>=2.0.3 +pandas>=2.2.3 \ No newline at end of file diff --git a/stats/config/utils/requirements.txt b/stats/config/utils/requirements.txt deleted file mode 100644 index b1dbf86c1..000000000 --- a/stats/config/utils/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -tkcalendar>=1.6.1 -croniter>=2.0.3 \ No newline at end of file diff --git a/stats/stats-server/src/runtime_setup.rs b/stats/stats-server/src/runtime_setup.rs index c28bfb275..8a8925926 100644 --- a/stats/stats-server/src/runtime_setup.rs +++ b/stats/stats-server/src/runtime_setup.rs @@ -296,6 +296,7 @@ impl RuntimeSetup { Arc::new(NativeCoinHoldersGrowthGroup), Arc::new(NewNativeCoinTransfersGroup), Arc::new(TxnsStats24hGroup), + Arc::new(VerifiedContractsPageGroup), ] } diff --git a/stats/stats-server/tests/it/counters.rs b/stats/stats-server/tests/it/counters.rs index e1100e708..13cc6b933 100644 --- a/stats/stats-server/tests/it/counters.rs +++ b/stats/stats-server/tests/it/counters.rs @@ -49,6 +49,8 @@ async fn test_counters_ok() { "completedTxns", "lastNewContracts", "lastNewVerifiedContracts", + "newContracts24h", + "newVerifiedContracts24h", "totalAccounts", "totalAddresses", "totalBlocks", diff --git a/stats/stats/src/charts/counters/mod.rs b/stats/stats/src/charts/counters/mod.rs index 3b4d450fe..b5611fb9f 100644 --- a/stats/stats/src/charts/counters/mod.rs +++ b/stats/stats/src/charts/counters/mod.rs @@ -2,6 +2,8 @@ mod average_block_time; mod completed_txns; mod last_new_contracts; mod last_new_verified_contracts; +mod new_contracts_24h; +mod new_verified_contracts_24h; mod pending_txns; mod total_accounts; mod total_addresses; @@ -23,6 +25,8 @@ pub use average_block_time::AverageBlockTime; pub use completed_txns::CompletedTxns; pub use last_new_contracts::LastNewContracts; pub use last_new_verified_contracts::LastNewVerifiedContracts; +pub use new_contracts_24h::NewContracts24h; +pub use new_verified_contracts_24h::NewVerifiedContracts24h; pub use pending_txns::PendingTxns; pub use total_accounts::TotalAccounts; pub use total_addresses::TotalAddresses; diff --git a/stats/stats/src/charts/counters/new_contracts_24h.rs b/stats/stats/src/charts/counters/new_contracts_24h.rs new file mode 100644 index 000000000..d28711a15 --- /dev/null +++ b/stats/stats/src/charts/counters/new_contracts_24h.rs @@ -0,0 +1,81 @@ +use crate::{ + charts::db_interaction::utils::interval_24h_filter, + data_source::{ + kinds::{ + data_manipulation::map::MapToString, + local_db::DirectPointLocalDbChartSource, + remote_db::{PullOneValue, RemoteDatabaseSource, StatementFromUpdateTime}, + }, + types::BlockscoutMigrations, + }, + ChartProperties, MissingDatePolicy, Named, +}; + +use blockscout_db::entity::transactions; +use chrono::{DateTime, NaiveDate, Utc}; +use entity::sea_orm_active_enums::ChartType; +use migration::{Asterisk, Func, IntoColumnRef}; +use sea_orm::{prelude::*, DbBackend, IntoSimpleExpr, QuerySelect, QueryTrait}; + +pub struct NewContracts24hStatement; + +impl StatementFromUpdateTime for NewContracts24hStatement { + fn get_statement( + update_time: DateTime, + _completed_migrations: &BlockscoutMigrations, + ) -> sea_orm::Statement { + transactions::Entity::find() + .select_only() + .filter(transactions::Column::Status.eq(1)) + .filter(interval_24h_filter( + transactions::Column::CreatedContractCodeIndexedAt.into_simple_expr(), + update_time, + )) + .expr_as(Func::count(Asterisk.into_column_ref()), "value") + .build(DbBackend::Postgres) + } +} + +pub type NewContracts24hRemote = + RemoteDatabaseSource>; + +pub struct Properties; + +impl Named for Properties { + fn name() -> String { + "newContracts24h".into() + } +} + +impl ChartProperties for Properties { + type Resolution = NaiveDate; + + fn chart_type() -> ChartType { + ChartType::Counter + } + fn missing_date_policy() -> MissingDatePolicy { + MissingDatePolicy::FillPrevious + } +} + +/// Does not include contracts from internal txns +/// (for performance reasons) +pub type NewContracts24h = + DirectPointLocalDbChartSource, Properties>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{point_construction::dt, simple_test::simple_test_counter}; + + #[tokio::test] + #[ignore = "needs database to run"] + async fn update_new_contracts_24h() { + simple_test_counter::( + "update_new_contracts_24h", + "8", + Some(dt("2022-11-11T16:30:00")), + ) + .await; + } +} diff --git a/stats/stats/src/charts/counters/new_verified_contracts_24h.rs b/stats/stats/src/charts/counters/new_verified_contracts_24h.rs new file mode 100644 index 000000000..4f64ebd81 --- /dev/null +++ b/stats/stats/src/charts/counters/new_verified_contracts_24h.rs @@ -0,0 +1,78 @@ +use crate::{ + charts::db_interaction::utils::interval_24h_filter, + data_source::{ + kinds::{ + data_manipulation::map::MapToString, + local_db::DirectPointLocalDbChartSource, + remote_db::{PullOneValue, RemoteDatabaseSource, StatementFromUpdateTime}, + }, + types::BlockscoutMigrations, + }, + ChartProperties, MissingDatePolicy, Named, +}; + +use blockscout_db::entity::smart_contracts; +use chrono::{DateTime, NaiveDate, Utc}; +use entity::sea_orm_active_enums::ChartType; +use migration::{Asterisk, Func, IntoColumnRef}; +use sea_orm::{prelude::*, DbBackend, IntoSimpleExpr, QuerySelect, QueryTrait}; + +pub struct NewVerifiedContracts24hStatement; + +impl StatementFromUpdateTime for NewVerifiedContracts24hStatement { + fn get_statement( + update_time: DateTime, + _completed_migrations: &BlockscoutMigrations, + ) -> sea_orm::Statement { + smart_contracts::Entity::find() + .select_only() + .filter(interval_24h_filter( + smart_contracts::Column::InsertedAt.into_simple_expr(), + update_time, + )) + .expr_as(Func::count(Asterisk.into_column_ref()), "value") + .build(DbBackend::Postgres) + } +} + +pub type NewVerifiedContracts24hRemote = + RemoteDatabaseSource>; + +pub struct Properties; + +impl Named for Properties { + fn name() -> String { + "newVerifiedContracts24h".into() + } +} + +impl ChartProperties for Properties { + type Resolution = NaiveDate; + + fn chart_type() -> ChartType { + ChartType::Counter + } + fn missing_date_policy() -> MissingDatePolicy { + MissingDatePolicy::FillPrevious + } +} + +pub type NewVerifiedContracts24h = + DirectPointLocalDbChartSource, Properties>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{point_construction::dt, simple_test::simple_test_counter}; + + #[tokio::test] + #[ignore = "needs database to run"] + async fn update_new_verified_contracts_24h() { + simple_test_counter::( + "update_new_verified_contracts_24h", + "1", + Some(dt("2022-11-16T6:30:00")), + ) + .await; + } +} diff --git a/stats/stats/src/charts/counters/total_contracts.rs b/stats/stats/src/charts/counters/total_contracts.rs index 2f856311b..11cb028d3 100644 --- a/stats/stats/src/charts/counters/total_contracts.rs +++ b/stats/stats/src/charts/counters/total_contracts.rs @@ -14,7 +14,7 @@ use crate::{ use blockscout_db::entity::addresses; use chrono::{DateTime, NaiveDate, Utc}; use entity::sea_orm_active_enums::ChartType; -use sea_orm::prelude::*; +use sea_orm::{prelude::*, QuerySelect}; pub struct TotalContractsQueryBehaviour; @@ -26,7 +26,9 @@ impl RemoteQueryBehaviour for TotalContractsQueryBehaviour { _range: UniversalRange>, ) -> Result { let value = addresses::Entity::find() + .select_only() .filter(addresses::Column::ContractCode.is_not_null()) + // seems to not introduce a significant performance penalty .filter(addresses::Column::InsertedAt.lte(cx.time)) .count(cx.blockscout) .await diff --git a/stats/stats/src/charts/counters/total_verified_contracts.rs b/stats/stats/src/charts/counters/total_verified_contracts.rs index 6acf9712c..236fde182 100644 --- a/stats/stats/src/charts/counters/total_verified_contracts.rs +++ b/stats/stats/src/charts/counters/total_verified_contracts.rs @@ -1,14 +1,40 @@ use crate::{ - data_source::kinds::{ - data_manipulation::{last_point::LastPoint, map::StripExt}, - local_db::DirectPointLocalDbChartSource, + data_source::{ + kinds::{ + data_manipulation::map::MapToString, + local_db::DirectPointLocalDbChartSource, + remote_db::{PullOneValue, RemoteDatabaseSource, StatementFromUpdateTime}, + }, + types::BlockscoutMigrations, }, - lines::VerifiedContractsGrowth, ChartProperties, MissingDatePolicy, Named, }; -use chrono::NaiveDate; +use blockscout_db::entity::smart_contracts; +use chrono::{DateTime, NaiveDate, Utc}; use entity::sea_orm_active_enums::ChartType; +use sea_orm::{ + sea_query::{Asterisk, Func, IntoColumnRef}, + ColumnTrait, DbBackend, EntityTrait, QueryFilter, QuerySelect, QueryTrait, Statement, +}; + +pub struct TotalVerifiedContractsStatement; + +impl StatementFromUpdateTime for TotalVerifiedContractsStatement { + fn get_statement( + update_time: DateTime, + _completed_migrations: &BlockscoutMigrations, + ) -> Statement { + smart_contracts::Entity::find() + .select_only() + .filter(smart_contracts::Column::InsertedAt.lte(update_time)) + .expr_as(Func::count(Asterisk.into_column_ref()), "value") + .build(DbBackend::Postgres) + } +} + +pub type TotalVerifiedContractsRemote = + RemoteDatabaseSource>; pub struct Properties; @@ -30,7 +56,7 @@ impl ChartProperties for Properties { } pub type TotalVerifiedContracts = - DirectPointLocalDbChartSource>, Properties>; + DirectPointLocalDbChartSource, Properties>; #[cfg(test)] mod tests { diff --git a/stats/stats/src/charts/db_interaction/mod.rs b/stats/stats/src/charts/db_interaction/mod.rs index 20abc9520..745fdc1b2 100644 --- a/stats/stats/src/charts/db_interaction/mod.rs +++ b/stats/stats/src/charts/db_interaction/mod.rs @@ -1,4 +1,5 @@ //! Abstracted interaction with DB pub mod read; +pub mod utils; pub mod write; diff --git a/stats/stats/src/charts/db_interaction/utils.rs b/stats/stats/src/charts/db_interaction/utils.rs new file mode 100644 index 000000000..d408aac74 --- /dev/null +++ b/stats/stats/src/charts/db_interaction/utils.rs @@ -0,0 +1,16 @@ +use chrono::{DateTime, Utc}; +use sea_orm::sea_query::{Expr, SimpleExpr}; + +pub fn interval_24h_filter( + timestamp_expr: SimpleExpr, + filter_24h_until: DateTime, +) -> SimpleExpr { + Expr::cust_with_exprs( + "$1 - $2 at time zone 'UTC' <= interval '24 hours'", + [Expr::value(filter_24h_until), timestamp_expr.clone()], + ) + .and(Expr::cust_with_exprs( + "$1 - $2 at time zone 'UTC' >= interval '0 hours'", + [Expr::value(filter_24h_until), timestamp_expr], + )) +} diff --git a/stats/stats/src/data_source/kinds/remote_db/mod.rs b/stats/stats/src/data_source/kinds/remote_db/mod.rs index 0fbdb4b56..5756563fb 100644 --- a/stats/stats/src/data_source/kinds/remote_db/mod.rs +++ b/stats/stats/src/data_source/kinds/remote_db/mod.rs @@ -34,8 +34,8 @@ use crate::{ }; pub use query::{ - PullAllWithAndSort, PullEachWith, PullOne, PullOne24hCached, StatementForOne, - StatementFromRange, StatementFromTimespan, + PullAllWithAndSort, PullEachWith, PullOne, PullOne24hCached, PullOneValue, StatementForOne, + StatementFromRange, StatementFromTimespan, StatementFromUpdateTime, }; /// See [module-level documentation](self) diff --git a/stats/stats/src/data_source/kinds/remote_db/query/mod.rs b/stats/stats/src/data_source/kinds/remote_db/query/mod.rs index cad537e1c..f131797be 100644 --- a/stats/stats/src/data_source/kinds/remote_db/query/mod.rs +++ b/stats/stats/src/data_source/kinds/remote_db/query/mod.rs @@ -4,4 +4,4 @@ mod one; pub use all::{PullAllWithAndSort, StatementFromRange}; pub use each::{PullEachWith, StatementFromTimespan}; -pub use one::{PullOne, PullOne24hCached, StatementForOne}; +pub use one::{PullOne, PullOne24hCached, PullOneValue, StatementForOne, StatementFromUpdateTime}; diff --git a/stats/stats/src/data_source/kinds/remote_db/query/one.rs b/stats/stats/src/data_source/kinds/remote_db/query/one.rs index 32bc05e22..b0cf95ae3 100644 --- a/stats/stats/src/data_source/kinds/remote_db/query/one.rs +++ b/stats/stats/src/data_source/kinds/remote_db/query/one.rs @@ -1,15 +1,15 @@ use std::marker::{PhantomData, Send}; use chrono::{DateTime, NaiveDate, TimeDelta, Utc}; -use sea_orm::{FromQueryResult, Statement}; +use sea_orm::{FromQueryResult, Statement, TryGetable}; use crate::{ data_source::{ kinds::remote_db::RemoteQueryBehaviour, - types::{BlockscoutMigrations, Cacheable, UpdateContext}, + types::{BlockscoutMigrations, Cacheable, UpdateContext, WrappedValue}, }, range::{inclusive_range_to_exclusive, UniversalRange}, - types::TimespanValue, + types::{Timespan, TimespanValue}, ChartError, }; @@ -55,6 +55,44 @@ where } } +pub trait StatementFromUpdateTime { + fn get_statement( + update_time: DateTime, + completed_migrations: &BlockscoutMigrations, + ) -> Statement; +} + +/// Just like `PullOne` but timespan is taken from update time +pub struct PullOneValue(PhantomData<(S, Resolution, Value)>) +where + S: StatementFromUpdateTime, + Resolution: Timespan + Ord + Send, + Value: Send + TryGetable; + +impl RemoteQueryBehaviour for PullOneValue +where + S: StatementFromUpdateTime, + Resolution: Timespan + Ord + Send, + Value: Send + TryGetable, +{ + type Output = TimespanValue; + + async fn query_data( + cx: &UpdateContext<'_>, + _range: UniversalRange>, + ) -> Result, ChartError> { + let query = S::get_statement(cx.time, &cx.blockscout_applied_migrations); + let timespan = Resolution::from_date(cx.time.date_naive()); + let value = WrappedValue::::find_by_statement(query) + .one(cx.blockscout) + .await + .map_err(ChartError::BlockscoutDB)? + .ok_or_else(|| ChartError::Internal("query returned nothing".into()))? + .value; + Ok(TimespanValue { timespan, value }) + } +} + /// Will reuse result for the same produced query within one update /// (based on update context) pub struct PullOne24hCached(PhantomData<(S, Value)>) diff --git a/stats/stats/src/tests/mock_blockscout.rs b/stats/stats/src/tests/mock_blockscout.rs index 363d3052d..9d5c88ad0 100644 --- a/stats/stats/src/tests/mock_blockscout.rs +++ b/stats/stats/src/tests/mock_blockscout.rs @@ -141,7 +141,8 @@ pub async fn fill_mock_blockscout_data(blockscout: &DatabaseConnection, max_date "2022-11-14T12:00:00", "2022-11-15T15:00:00", "2022-11-16T23:59:59", - "2022-11-17T00:00:00", + // not used + // "2022-11-17T00:00:00", ] .into_iter() .map(|val| NaiveDateTime::from_str(val).unwrap()); @@ -467,6 +468,16 @@ fn mock_transaction( let value = (tx_type.needs_value()) .then_some(1_000_000_000_000) .unwrap_or_default(); + let created_contract_code_indexed_at = match &tx_type { + TxType::ContractCreation(_) => Some( + block + .timestamp + .as_ref() + .checked_add_signed(TimeDelta::minutes(10)) + .unwrap(), + ), + _ => None, + }; let created_contract_address_hash = match tx_type { TxType::ContractCreation(contract_address) => Some(contract_address), _ => None, @@ -495,6 +506,7 @@ fn mock_transaction( index: Set(Some(index)), status: Set(Some(1)), created_contract_address_hash: Set(created_contract_address_hash), + created_contract_code_indexed_at: Set(created_contract_code_indexed_at), ..Default::default() } } diff --git a/stats/stats/src/update_group.rs b/stats/stats/src/update_group.rs index dec4cbe55..db838457e 100644 --- a/stats/stats/src/update_group.rs +++ b/stats/stats/src/update_group.rs @@ -532,22 +532,21 @@ mod tests { use tokio::sync::Mutex; use crate::{ - counters::TotalVerifiedContracts, data_source::DataSource, - lines::{NewVerifiedContracts, VerifiedContractsGrowth}, + lines::{ContractsGrowth, NewContracts}, update_group::InitializationError, }; use super::SyncUpdateGroup; construct_update_group!(GroupWithoutDependencies { - charts: [TotalVerifiedContracts], + charts: [ContractsGrowth], }); #[test] fn new_checks_mutexes() { let mutexes: BTreeMap>> = [( - TotalVerifiedContracts::mutex_id().unwrap(), + ContractsGrowth::mutex_id().unwrap(), Arc::new(Mutex::new(())), )] .into(); @@ -566,10 +565,7 @@ mod tests { .map_err(sorted_init_error) .unwrap_err(), sorted_init_error(InitializationError { - missing_mutexes: vec![ - VerifiedContractsGrowth::mutex_id().unwrap(), - NewVerifiedContracts::mutex_id().unwrap() - ] + missing_mutexes: vec![NewContracts::mutex_id().unwrap(),] }) ); } diff --git a/stats/stats/src/update_groups.rs b/stats/stats/src/update_groups.rs index a467f0268..71f6af573 100644 --- a/stats/stats/src/update_groups.rs +++ b/stats/stats/src/update_groups.rs @@ -170,10 +170,6 @@ construct_update_group!(NewContractsGroup { ContractsGrowthWeekly, ContractsGrowthMonthly, ContractsGrowthYearly, - // currently can be in a separate group but after #845 - // it's expected to join this group. placing it here to avoid - // updating config after the fix (todo: remove (#845)) - TotalContracts, LastNewContracts, ], }); @@ -201,7 +197,6 @@ construct_update_group!(NewVerifiedContractsGroup { VerifiedContractsGrowthWeekly, VerifiedContractsGrowthMonthly, VerifiedContractsGrowthYearly, - TotalVerifiedContracts, LastNewVerifiedContracts, ], }); @@ -229,3 +224,18 @@ construct_update_group!(NewNativeCoinTransfersGroup { TotalNativeCoinTransfers, ], }); + +// Charts returned in contracts endpoint. +// +// They don't depend on each other, but single group +// will make scheduling simpler + ensure they update +// as close to each other as possible without overloading DB +// by running concurrently. +construct_update_group!(VerifiedContractsPageGroup { + charts: [ + TotalContracts, + NewContracts24h, + TotalVerifiedContracts, + NewVerifiedContracts24h, + ], +});