Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Meraki EOL Manager

Obtain a lifecycle report from all of your Cisco Meraki organizations, detailing how many of your devices have EoL announcements published.

# Table of Contents
Expand Down Expand Up @@ -36,7 +37,12 @@ This script allows you to obtain a report of all of the End of Sales and End of
## How to Use

1. Clone repo to your working directory
2. Edit `config.py` with your API Key in between the quotation marks next to `api_key = `
2. Edit `config.py`
- Set your API Key in between the quotation marks next to `api_key =`
- Set `html_output` to `TRUE` if you would like an HTML and PDF report
- Set `output_log` to `TRUE` if you would like an output file from all Meraki API calls
- Set `print_console` to `TRUE` if you would like to see Meraki API output on the screen during script run.

3. Run `pip install -r requirements.txt` from your terminal
4. Run the script `python main.py`
5. You will be prompted with a list of the organizations your API Key has access to
Expand Down
5 changes: 4 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
api_key = 'MERAKI_API_KEY_HERE'
api_key = "MERAKI_API_KEY_HERE"
html_output = False # Set to True to output to HTML and PDF
output_log = False # Set to True to output to log file
print_console = False # Set to True to enable console output
150 changes: 97 additions & 53 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,114 +1,141 @@
import pandas as pd
import logging
import sys

import meraki
import pandas as pd
import requests
from bs4 import BeautifulSoup
from PyQt5 import QtWebEngineWidgets, QtWidgets

import config
import sys
import pathlib
from PyQt5 import QtCore, QtWidgets, QtWebEngineWidgets

logging.basicConfig(filename="meraki_lifecycle.log", level=logging.INFO)
logger = logging.getLogger(__name__)

api_key = config.api_key


def fetch_eol_data():
url = 'https://documentation.meraki.com/General_Administration/Other_Topics/Meraki_End-of-Life_(EOL)_Products_and_Dates'
url = "https://documentation.meraki.com/General_Administration/Other_Topics/Meraki_End-of-Life_(EOL)_Products_and_Dates"
dfs = pd.read_html(url)
requested_url = requests.get(url)
soup = BeautifulSoup(requested_url.text, 'html.parser')
table = soup.find('table')
soup = BeautifulSoup(requested_url.text, "html.parser")
table = soup.find("table")

links = []
for row in table.find_all('tr'):
for td in row.find_all('td'):
for row in table.find_all("tr"):
for td in row.find_all("td"):
sublinks = []
if td.find_all('a'):
for a in td.find_all('a'):
if td.find_all("a"):
for a in td.find_all("a"):
sublinks.append(str(a))
links.append(sublinks)

eol_df = dfs[0]
eol_df['Upgrade Path'] = links
eol_df["Upgrade Path"] = links
return eol_df


def get_inventory(dashboard, org_list):
inventory_list = []
for org in org_list:
org_name = org['name']
org_id = org['id']
print(f"\nFetching networks and devices for organization: {org_name} (ID: {org_id})")
org_name = org["name"]
org_id = org["id"]
print(
f"\nFetching networks and devices for organization: {org_name} (ID: {org_id})"
)

try:
# Fetch networks
networks = dashboard.organizations.getOrganizationNetworks(org_id)
print(f"Networks found: {len(networks)}")
print(f"Network IDs: {[net['id'] for net in networks]}")
logger.info(f"Networks found: {len(networks)}")
logger.debug(f"Network IDs: {[net['id'] for net in networks]}")

devices = dashboard.organizations.getOrganizationDevices(org_id)
print(f"Devices found: {len(devices)}")
logger.info(f"Devices found: {len(devices)}")

if not devices:
print(f"No devices found for organization: {org_name}")
logger.warning(f"No devices found for organization: {org_name}")
continue

network_map = {net["id"]: net["name"] for net in networks}
for device in devices:
device["networkName"] = network_map.get(device.get("networkId"), "Unassigned")
device["networkName"] = network_map.get(
device.get("networkId"), "Unassigned"
)

inventory_list.append({f"{org_name} - {org_id}": devices})
print(f"Fetched {len(devices)} devices for {org_name}")
logger.info(f"Fetched {len(devices)} devices for {org_name}")

except meraki.exceptions.APIError as e:
print(f"Meraki API error for {org_name}: {e}")
logger.error(f"Meraki API error for {org_name}: {e}")
except Exception as ex:
print(f"General error for {org_name}: {ex}")
logger.error(f"General error for {org_name}: {ex}")

return inventory_list


def process_inventory(inventory_list, eol_df):
eol_report_list = []
for inventory in inventory_list:
for key in inventory.keys():
if not inventory[key]:
print(f"Organization: {key}, Devices Count: 0")
logger.info(f"Organization: {key}, Devices Count: 0")
continue

print(f"Organization: {key}, Devices Count: {len(inventory[key])}")
inventory_df = pd.DataFrame(inventory[key])

if inventory_df.empty:
print(f"Inventory DataFrame for {key} is empty. Skipping...")
logger.info(f"Inventory DataFrame for {key} is empty. Skipping...")
continue

print(f"Inventory DataFrame for {key}:\n{inventory_df.head()}")
logger.debug(f"Inventory DataFrame for {key}:\n{inventory_df.head()}")

inventory_unassigned_df = inventory_df.loc[inventory_df['networkId'].isna()].copy() if 'networkId' in inventory_df else pd.DataFrame()
inventory_assigned_df = inventory_df.loc[~inventory_df['networkId'].isna()].copy() if 'networkId' in inventory_df else pd.DataFrame()
inventory_unassigned_df = (
inventory_df.loc[inventory_df["networkId"].isna()].copy()
if "networkId" in inventory_df
else pd.DataFrame()
)
inventory_assigned_df = (
inventory_df.loc[~inventory_df["networkId"].isna()].copy()
if "networkId" in inventory_df
else pd.DataFrame()
)

if inventory_assigned_df.empty:
print(f"No assigned devices found for {key}. Skipping lifecycle processing...")
logger.info(
f"No assigned devices found for {key}. Skipping lifecycle processing..."
)
continue

inventory_assigned_df['lifecycle'] = ""
inventory_assigned_df["lifecycle"] = ""

inventory_assigned_df['model'].isin(eol_df['Product']).astype(int)
inventory_assigned_df["model"].isin(eol_df["Product"]).astype(int)

eol_report = eol_df.copy()
eol_report['Total Units'] = eol_report['Product'].map(inventory_assigned_df['model'].value_counts())
eol_report['Total Units'] = eol_report['Total Units'].fillna(0).astype(int)
eol_report = eol_report[eol_report['Total Units'] > 0]
eol_report = eol_report.sort_values(by=["Total Units"], ascending=False).reset_index(drop=True)
eol_report["Total Units"] = eol_report["Product"].map(
inventory_assigned_df["model"].value_counts()
)
eol_report["Total Units"] = eol_report["Total Units"].fillna(0).astype(int)
eol_report = eol_report[eol_report["Total Units"] > 0]
eol_report = eol_report.sort_values(
by=["Total Units"], ascending=False
).reset_index(drop=True)
eol_report_list.append({"name": key, "report": eol_report})

return eol_report_list


def generate_html(eol_report_list):
page_title_text = 'Cisco Meraki Lifecycle Report'
title_text = 'Cisco Meraki Lifecycle Report'
text = '''
page_title_text = "Cisco Meraki Lifecycle Report"
title_text = "Cisco Meraki Lifecycle Report"
text = """
This report lists all of your equipment currently in use that has an end of life announcement. They are ordered by the
total units column, and the Upgrade Path column links you to the EoS announcement with recommendations on upgrade paths.
'''
"""

html = f'''
html = f"""
<html>
<style>
body {{font-family: Inter, Arial, sans-serif; margin: 15px;}}
Expand All @@ -128,21 +155,23 @@ def generate_html(eol_report_list):
<body>
<h1>{title_text}</h1>
<p>{text}</p>
'''
"""

for report in eol_report_list:
add_html = f'''
<h2>{report['name']}</h2>
{report['report'].to_html(render_links=True, escape=False, index=False)}
'''
add_html = f"""
<h2>{report["name"]}</h2>
{report["report"].to_html(render_links=True, escape=False, index=False)}
"""
html += add_html

html += '</body></html>'
html += "</body></html>"
return html


def save_reports(html):
with open('lifecycle_report.html', 'w') as f:
f.write(html)
if config.html_output:
with open("lifecycle_report.html", "w") as f:
f.write(html)

app = QtWidgets.QApplication(sys.argv)
page = QtWebEngineWidgets.QWebEnginePage()
Expand All @@ -163,17 +192,31 @@ def handle_load_finished(status):
page.setHtml(html)
app.exec_()


def main():
dashboard = meraki.DashboardAPI(api_key)
dashboard = meraki.DashboardAPI(
api_key=api_key,
output_log=config.output_log,
print_console=config.print_console,
suppress_logging=True,
)
eol_df = fetch_eol_data()

orgs = dashboard.organizations.getOrganizations()
print("Your API Key has access to the following organizations:")
for i, org in enumerate(orgs, start=1):
print(f"{i} - {org['name']}")
print(f"{len(orgs) + 1} - All Organizations")

choice = input("Enter the number(s) of organizations to fetch inventory for (comma-separated): ")
int_choice = [int(x) - 1 for x in choice.split(',')]
choice = input(
"Enter the number(s) of organizations to fetch inventory for (comma-separated): "
)

## Handle the additional "all" option
if choice == str(len(orgs) + 1):
int_choice = list(range(len(orgs)))
else:
int_choice = [int(x) - 1 for x in choice.split(",")]
org_list = [orgs[i] for i in int_choice]

inventory_list = get_inventory(dashboard, org_list)
Expand All @@ -182,5 +225,6 @@ def main():
html = generate_html(eol_report_list)
save_reports(html)


if __name__ == "__main__":
main()