db-agent-kit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- db_agent/__init__.py +3 -0
- db_agent/cli.py +32 -0
- db_agent/engines/__init__.py +36 -0
- db_agent/engines/databricks/__init__.py +1 -0
- db_agent/engines/databricks/checks.py +221 -0
- db_agent/engines/databricks/cli.py +61 -0
- db_agent/engines/databricks/client.py +86 -0
- db_agent/engines/databricks/cost.py +96 -0
- db_agent/engines/databricks/health.py +156 -0
- db_agent/engines/databricks/model.py +29 -0
- db_agent/engines/databricks/queries.py +98 -0
- db_agent/engines/databricks/render.py +78 -0
- db_agent/engines/databricks/runtimes.py +58 -0
- db_agent/engines/databricks/sqlguard.py +80 -0
- db_agent/engines/sqlserver/__init__.py +11 -0
- db_agent/engines/sqlserver/classify.py +102 -0
- db_agent/engines/sqlserver/cli.py +265 -0
- db_agent/engines/sqlserver/data/classification.yaml +29 -0
- db_agent/engines/sqlserver/db.py +183 -0
- db_agent/engines/sqlserver/guard.py +190 -0
- db_agent/engines/sqlserver/inventory.py +324 -0
- db_agent/engines/sqlserver/outputs.py +299 -0
- db_agent/engines/sqlserver/rdl.py +269 -0
- db_agent/engines/sqlserver/report.py +120 -0
- db_agent/engines/sqlserver/risks.py +184 -0
- db_agent/engines/sqlserver/rules.py +141 -0
- db_agent/engines/sqlserver/sqlrefs.py +103 -0
- db_agent/engines/sqlserver/ssrs.py +230 -0
- db_agent/engines/sqlserver/templates/summary.html.j2 +142 -0
- db_agent/engines/sqlserver/usage.py +127 -0
- db_agent/engines/sqlserver/validate.py +238 -0
- db_agent_kit-0.2.0.dist-info/METADATA +192 -0
- db_agent_kit-0.2.0.dist-info/RECORD +36 -0
- db_agent_kit-0.2.0.dist-info/WHEEL +4 -0
- db_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
- db_agent_kit-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Upgrade-risk rules for T-SQL module code.
|
|
2
|
+
|
|
3
|
+
Built from Microsoft's lists, fetched 2026-11-05:
|
|
4
|
+
DEPRECATED: Deprecated Database Engine Features in SQL Server 2016 (the cumulative baseline)
|
|
5
|
+
DISCONTINUED: Discontinued Database Engine Functionality in SQL Server
|
|
6
|
+
Microsoft's pages for 2017, 2019 and 2022 add a few items (for example Distributed Replay, Machine
|
|
7
|
+
Learning Server). They are not detectable from module code and are not in this table.
|
|
8
|
+
See docs/risk-rules.md for the list and what each rule is based on.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
DOC_DEPRECATED = (
|
|
17
|
+
"https://learn.microsoft.com/en-us/previous-versions/sql/database-engine/"
|
|
18
|
+
"deprecated-database-engine-features-in-sql-server-2016"
|
|
19
|
+
)
|
|
20
|
+
DOC_DISCONTINUED = "https://learn.microsoft.com/en-us/sql/database-engine/discontinued-database-engine-functionality-in-sql-server"
|
|
21
|
+
DOC_COMPAT = "https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql-compatibility-level"
|
|
22
|
+
DOC_AZURE_DIFF = "https://learn.microsoft.com/en-us/azure/azure-sql/database/transact-sql-tsql-differences-sql-server"
|
|
23
|
+
|
|
24
|
+
# Compatibility level for each major version.
|
|
25
|
+
COMPAT_BY_MAJOR = {13: 130, 14: 140, 15: 150, 16: 160, 17: 170}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CodeRule:
|
|
30
|
+
id: str
|
|
31
|
+
severity: str # high | medium | low
|
|
32
|
+
title: str
|
|
33
|
+
pattern: re.Pattern
|
|
34
|
+
replacement: str
|
|
35
|
+
doc: str = DOC_DEPRECATED
|
|
36
|
+
needs_strings: bool = False # the rule looks inside string literals (for example HASHBYTES('MD5', ...))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _r(p: str) -> re.Pattern:
|
|
40
|
+
return re.compile(p, re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SYS_PROCS = (
|
|
44
|
+
"sp_addlogin sp_droplogin sp_adduser sp_dropuser sp_grantdbaccess sp_revokedbaccess sp_addrole "
|
|
45
|
+
"sp_droprole sp_password sp_approlepassword sp_changeobjectowner sp_defaultdb sp_defaultlanguage "
|
|
46
|
+
"sp_denylogin sp_grantlogin sp_revokelogin sp_addapprole sp_dropapprole sp_change_users_login "
|
|
47
|
+
"sp_depends sp_renamedb sp_dbcmptlevel sp_attach_db sp_attach_single_file_db sp_addtype sp_droptype "
|
|
48
|
+
"sp_bindefault sp_unbindefault sp_bindrule sp_unbindrule sp_helpdevice sp_lock sp_indexoption "
|
|
49
|
+
"sp_dbremove sp_addremotelogin sp_addserver sp_dropremotelogin sp_helpremotelogin sp_remoteoption "
|
|
50
|
+
"sp_resetstatus sp_bindsession sp_getbindtoken sp_addextendedproc sp_dropextendedproc "
|
|
51
|
+
"sp_helpextendedproc xp_grantlogin xp_revokelogin xp_loginconfig sp_certify_removable "
|
|
52
|
+
"sp_create_removable sp_srvrolepermission sp_dbfixedrolepermission sp_trace_create sp_trace_setevent "
|
|
53
|
+
"sp_trace_setfilter sp_trace_setstatus sp_db_vardecimal_storage_format "
|
|
54
|
+
"sp_estimated_rowsize_reduction_for_vardecimal"
|
|
55
|
+
).split()
|
|
56
|
+
|
|
57
|
+
_COMPAT_VIEWS = (
|
|
58
|
+
"sysaltfiles syscacheobjects syscolumns syscomments sysconfigures sysconstraints syscurconfigs "
|
|
59
|
+
"sysdatabases sysdepends sysdevices sysfilegroups sysfiles sysforeignkeys sysfulltextcatalogs "
|
|
60
|
+
"sysindexes sysindexkeys syslockinfo syslogins sysmembers sysmessages sysobjects sysoledbusers "
|
|
61
|
+
"sysopentapes sysperfinfo syspermissions sysprocesses sysprotects sysreferences sysremotelogins "
|
|
62
|
+
"sysservers systypes sysusers"
|
|
63
|
+
).split()
|
|
64
|
+
|
|
65
|
+
_HINTS = (
|
|
66
|
+
"NOLOCK|READUNCOMMITTED|HOLDLOCK|UPDLOCK|ROWLOCK|TABLOCKX|TABLOCK|READPAST|NOWAIT|XLOCK|PAGLOCK|"
|
|
67
|
+
"SERIALIZABLE|REPEATABLEREAD|READCOMMITTED"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
CODE_RULES: list[CodeRule] = [
|
|
71
|
+
CodeRule("dep-sp-removed-security-and-admin", "medium",
|
|
72
|
+
"Deprecated system stored procedure",
|
|
73
|
+
_r(r"\b(" + "|".join(_SYS_PROCS) + r")\b"),
|
|
74
|
+
"Use the current statement (CREATE LOGIN, CREATE USER, ALTER DATABASE, sys.dm_* views, Extended Events). See the replacement column in Microsoft's list."),
|
|
75
|
+
CodeRule("dep-compat-views", "medium",
|
|
76
|
+
"Compatibility system view (sysobjects, syscolumns and similar)",
|
|
77
|
+
_r(r"(?<![\w.])(?:(?:master|msdb|tempdb|\w+)\.)?(?:dbo\.|sys\.)?(" + "|".join(_COMPAT_VIEWS) + r")\b"),
|
|
78
|
+
"Use catalog views (sys.objects, sys.columns, sys.indexes, ...)."),
|
|
79
|
+
CodeRule("dep-set-rowcount", "low",
|
|
80
|
+
"SET ROWCOUNT (deprecated for INSERT, UPDATE and DELETE)",
|
|
81
|
+
_r(r"\bSET\s+ROWCOUNT\b"), "Use TOP. SET ROWCOUNT 0 only resets the setting; review before changing."),
|
|
82
|
+
CodeRule("dep-set-fmtonly", "medium", "SET FMTONLY",
|
|
83
|
+
_r(r"\bSET\s+FMTONLY\b"), "Use sp_describe_first_result_set or sys.dm_exec_describe_first_result_set."),
|
|
84
|
+
CodeRule("dep-group-by-all", "medium", "GROUP BY ALL",
|
|
85
|
+
_r(r"\bGROUP\s+BY\s+ALL\b"), "Rewrite with a derived table or UNION."),
|
|
86
|
+
CodeRule("dep-set-ansi-off", "medium", "SET ANSI_NULLS / ANSI_PADDING / CONCAT_NULL_YIELDS_NULL OFF, SET OFFSETS",
|
|
87
|
+
_r(r"\bSET\s+(ANSI_NULLS|ANSI_PADDING|CONCAT_NULL_YIELDS_NULL)\s+OFF\b|\bSET\s+OFFSETS\b"),
|
|
88
|
+
"These options are always ON in a future version. Test the code with them ON."),
|
|
89
|
+
CodeRule("dep-table-hint-without-with", "low", "Table hint without the WITH keyword",
|
|
90
|
+
_r(r"(\bWITH\s*)?\(\s*(?:" + _HINTS + r")\b"),
|
|
91
|
+
"Write WITH (NOLOCK), not (NOLOCK)."),
|
|
92
|
+
CodeRule("dep-textptr", "medium", "READTEXT / WRITETEXT / UPDATETEXT / TEXTPTR / TEXTVALID",
|
|
93
|
+
_r(r"\b(READTEXT|WRITETEXT|UPDATETEXT|TEXTPTR|TEXTVALID)\b"), "Use varchar(max), nvarchar(max), varbinary(max)."),
|
|
94
|
+
CodeRule("dep-dbcc", "low", "DBCC DBREINDEX / INDEXDEFRAG / SHOWCONTIG / PINTABLE / UNPINTABLE",
|
|
95
|
+
_r(r"\bDBCC\s+(DBREINDEX|INDEXDEFRAG|SHOWCONTIG|PINTABLE|UNPINTABLE)\b"),
|
|
96
|
+
"ALTER INDEX ... REBUILD / REORGANIZE, sys.dm_db_index_physical_stats."),
|
|
97
|
+
CodeRule("dep-functions", "low", "Deprecated function",
|
|
98
|
+
_r(r"\b(fn_get_sql|USER_ID|PERMISSIONS|INDEXKEY_PROPERTY|fn_virtualservernodes|fn_servershareddrives)\s*\("),
|
|
99
|
+
"fn_get_sql -> sys.dm_exec_sql_text, USER_ID -> DATABASE_PRINCIPAL_ID, PERMISSIONS -> sys.fn_my_permissions."),
|
|
100
|
+
CodeRule("dep-colon-colon-function", "low", "'::' function-calling syntax",
|
|
101
|
+
_r(r"::\s*fn_\w+"), "Use SELECT ... FROM sys.fn_name()."),
|
|
102
|
+
CodeRule("dep-setuser", "medium", "SETUSER", _r(r"\bSETUSER\b"), "Use EXECUTE AS."),
|
|
103
|
+
CodeRule("dep-grant-all", "low", "GRANT / DENY / REVOKE ALL",
|
|
104
|
+
_r(r"\b(GRANT|DENY|REVOKE)\s+ALL\b"), "Grant specific permissions."),
|
|
105
|
+
CodeRule("dep-weak-crypto", "high", "RC4 / DESX encryption or MD2, MD4, MD5, SHA, SHA1 hashing",
|
|
106
|
+
_r(r"\bALGORITHM\s*=\s*(RC4|RC4_128|DESX)\b|\bHASHBYTES\s*\(\s*'(MD2|MD4|MD5|SHA|SHA1)'"),
|
|
107
|
+
"Use AES for encryption and SHA2_256 or SHA2_512 for hashing.", needs_strings=True),
|
|
108
|
+
CodeRule("dep-xmldata", "low", "FOR XML ... XMLDATA", _r(r"\bXMLDATA\b"), "Use XSD generation."),
|
|
109
|
+
CodeRule("dep-sys-views", "low", "Deprecated catalog view",
|
|
110
|
+
_r(r"\bsys\.(sql_dependencies|numbered_procedures|numbered_procedure_parameters|traces|trace_events|trace_event_bindings|trace_categories|trace_columns|trace_subclass_values|soap_endpoints|endpoint_webmethods)\b"),
|
|
111
|
+
"sys.sql_dependencies -> sys.sql_expression_dependencies; SQL Trace views -> Extended Events."),
|
|
112
|
+
CodeRule("dep-identitycol-rowguidcol", "low", "IDENTITYCOL / ROWGUIDCOL as a column name",
|
|
113
|
+
_r(r"\b(IDENTITYCOL|ROWGUIDCOL)\b"), "Use $identity and $rowguid."),
|
|
114
|
+
CodeRule("dep-create-default-rule", "low", "CREATE DEFAULT / CREATE RULE",
|
|
115
|
+
_r(r"\bCREATE\s+(DEFAULT|RULE)\b|\bDROP\s+(DEFAULT|RULE)\b"), "Use DEFAULT and CHECK constraints."),
|
|
116
|
+
CodeRule("dep-raiserror-old", "low", "Old RAISERROR syntax without parentheses",
|
|
117
|
+
_r(r"\bRAISERROR\s+\d+\s+['@]"), "Use RAISERROR (msg, severity, state) or THROW. Not on Microsoft's deprecated list; removed earlier."),
|
|
118
|
+
CodeRule("dep-sqloledb", "medium", "SQLOLEDB provider",
|
|
119
|
+
_r(r"\bSQLOLEDB\b"), "Use Microsoft OLE DB Driver for SQL Server (MSOLEDBSQL)."),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def scan_code(code: str) -> list[tuple[CodeRule, int, str]]:
|
|
124
|
+
"""Return (rule, line number, matched text) for every rule hit. Line numbers start at 1.
|
|
125
|
+
|
|
126
|
+
Strings, comments and quoted names are blanked first, so dynamic SQL held in a string is
|
|
127
|
+
not scanned (documented limit).
|
|
128
|
+
"""
|
|
129
|
+
from .guard import sanitize
|
|
130
|
+
|
|
131
|
+
clean = sanitize(code, preserve_length=True)
|
|
132
|
+
with_strings = sanitize(code, preserve_length=True, keep_strings=True)
|
|
133
|
+
hits: list[tuple[CodeRule, int, str]] = []
|
|
134
|
+
for rule in CODE_RULES:
|
|
135
|
+
text = with_strings if rule.needs_strings else clean
|
|
136
|
+
for m in rule.pattern.finditer(text):
|
|
137
|
+
if rule.id == "dep-table-hint-without-with" and m.group(1):
|
|
138
|
+
continue # WITH (NOLOCK) is the correct form
|
|
139
|
+
line = clean.count("\n", 0, m.start()) + 1
|
|
140
|
+
hits.append((rule, line, m.group(0).strip()))
|
|
141
|
+
return hits
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Find the tables, views and procedures a dataset query uses. Best effort, with a confidence flag.
|
|
2
|
+
|
|
3
|
+
high : sqlglot parsed every statement.
|
|
4
|
+
medium : sqlglot parsed some statements; the rest were read with a text fallback.
|
|
5
|
+
low : sqlglot failed; names come from a text fallback only.
|
|
6
|
+
n/a : nothing to parse (empty, or a stored procedure call, or a non-SQL language).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import sqlglot
|
|
15
|
+
from sqlglot import exp
|
|
16
|
+
|
|
17
|
+
from .guard import sanitize
|
|
18
|
+
|
|
19
|
+
_FALLBACK_RE = re.compile(
|
|
20
|
+
r"\b(?:FROM|JOIN|INTO|UPDATE|MERGE\s+INTO)\s+((?:\[[^\]]+\]|[\w#@$]+)(?:\s*\.\s*(?:\[[^\]]+\]|[\w#@$]+)){0,3})",
|
|
21
|
+
re.IGNORECASE,
|
|
22
|
+
)
|
|
23
|
+
_EXEC_RE = re.compile(r"\bEXEC(?:UTE)?\s+((?:\[[^\]]+\]|[\w$]+)(?:\.(?:\[[^\]]+\]|[\w$]+)){0,2})", re.IGNORECASE)
|
|
24
|
+
_SQL_KEYWORDS = {"select", "values", "(", "lateral", "openquery", "openrowset"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Refs:
|
|
29
|
+
tables: list[str] = field(default_factory=list) # tables and views (cannot tell which from text)
|
|
30
|
+
procedures: list[str] = field(default_factory=list)
|
|
31
|
+
temp_tables: list[str] = field(default_factory=list)
|
|
32
|
+
confidence: str = "n/a"
|
|
33
|
+
error: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _clean_name(name: str) -> str:
|
|
37
|
+
return re.sub(r"\s*\.\s*", ".", name.replace("[", "").replace("]", "")).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fallback(sql: str) -> tuple[list[str], list[str]]:
|
|
41
|
+
clean = sanitize(sql, preserve_length=False)
|
|
42
|
+
tables = [m.group(1) for m in _FALLBACK_RE.finditer(sql)]
|
|
43
|
+
procs = [m.group(1) for m in _EXEC_RE.finditer(clean)]
|
|
44
|
+
return tables, procs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract(sql: str, command_type: str = "Text") -> Refs:
|
|
48
|
+
refs = Refs()
|
|
49
|
+
text = (sql or "").strip()
|
|
50
|
+
if not text:
|
|
51
|
+
return refs
|
|
52
|
+
if command_type == "StoredProcedure":
|
|
53
|
+
refs.procedures = [_clean_name(text)]
|
|
54
|
+
refs.confidence = "high"
|
|
55
|
+
return refs
|
|
56
|
+
if text.startswith("="):
|
|
57
|
+
refs.confidence = "n/a"
|
|
58
|
+
refs.error = "query is an expression, not static SQL"
|
|
59
|
+
return refs
|
|
60
|
+
tables: set[str] = set()
|
|
61
|
+
temps: set[str] = set()
|
|
62
|
+
procs: set[str] = set()
|
|
63
|
+
try:
|
|
64
|
+
trees = [t for t in sqlglot.parse(text, read="tsql") if t is not None]
|
|
65
|
+
except Exception as exc: # noqa: BLE001
|
|
66
|
+
t_fallback, p_fallback = _fallback(text)
|
|
67
|
+
refs.error = f"parse failed: {str(exc).splitlines()[0][:140]}"
|
|
68
|
+
refs.confidence = "low"
|
|
69
|
+
for n in t_fallback:
|
|
70
|
+
(temps if _clean_name(n).startswith("#") else tables).add(_clean_name(n))
|
|
71
|
+
procs.update(_clean_name(p) for p in p_fallback)
|
|
72
|
+
else:
|
|
73
|
+
degraded = False
|
|
74
|
+
for tree in trees:
|
|
75
|
+
if isinstance(tree, exp.Command):
|
|
76
|
+
degraded = True
|
|
77
|
+
t_fallback, p_fallback = _fallback(tree.sql(dialect="tsql"))
|
|
78
|
+
for n in t_fallback:
|
|
79
|
+
(temps if _clean_name(n).startswith("#") else tables).add(_clean_name(n))
|
|
80
|
+
procs.update(_clean_name(p) for p in p_fallback)
|
|
81
|
+
continue
|
|
82
|
+
ctes = {c.alias.lower() for c in tree.find_all(exp.CTE) if c.alias}
|
|
83
|
+
exec_targets = set()
|
|
84
|
+
for ex in tree.find_all(exp.Execute):
|
|
85
|
+
if isinstance(ex.this, exp.Table):
|
|
86
|
+
exec_targets.add(id(ex.this))
|
|
87
|
+
procs.add(".".join(p for p in (ex.this.catalog, ex.this.db, ex.this.name) if p))
|
|
88
|
+
for t in tree.find_all(exp.Table):
|
|
89
|
+
if not t.name or id(t) in exec_targets:
|
|
90
|
+
continue
|
|
91
|
+
if not t.args.get("db") and t.name.lower() in ctes:
|
|
92
|
+
continue
|
|
93
|
+
parts = [p for p in (t.catalog, t.db, t.name) if p]
|
|
94
|
+
is_temp = isinstance(t.this, exp.Identifier) and bool(t.this.args.get("temporary"))
|
|
95
|
+
if is_temp:
|
|
96
|
+
temps.add("#" + t.name)
|
|
97
|
+
else:
|
|
98
|
+
tables.add(".".join(parts))
|
|
99
|
+
refs.confidence = "medium" if degraded else "high"
|
|
100
|
+
refs.tables = sorted(tables, key=str.lower)
|
|
101
|
+
refs.temp_tables = sorted(temps)
|
|
102
|
+
refs.procedures = sorted(procs, key=str.lower)
|
|
103
|
+
return refs
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Build the SSRS report inventory from a folder of RDL files or from a ReportServer database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import csv
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import classify as classify_mod
|
|
12
|
+
from . import rdl, sqlrefs
|
|
13
|
+
from .db import SafeConnection
|
|
14
|
+
from .usage import UsageData
|
|
15
|
+
|
|
16
|
+
# Catalog.Type values used by SSRS. Folder=1, Report=2, Resource=3, LinkedReport=4, DataSource=5,
|
|
17
|
+
# Model=6, Dataset=8. Only 2, 4, 5 and 8 are read here.
|
|
18
|
+
Q_CATALOG = (
|
|
19
|
+
"SELECT ItemID, Path, Name, Type, LinkSourceID, Content FROM dbo.Catalog WHERE Type IN (2, 4, 5, 8)"
|
|
20
|
+
)
|
|
21
|
+
Q_SUBSCRIPTIONS = """
|
|
22
|
+
SELECT c.Path AS ItemPath, COUNT(*) AS subs,
|
|
23
|
+
SUM(CASE WHEN s.DataSettings IS NOT NULL THEN 1 ELSE 0 END) AS data_driven,
|
|
24
|
+
SUM(CASE WHEN s.DeliveryExtension <> 'Report Server Email' THEN 1 ELSE 0 END) AS non_email
|
|
25
|
+
FROM dbo.Subscriptions s JOIN dbo.Catalog c ON c.ItemID = s.Report_OID
|
|
26
|
+
GROUP BY c.Path
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_subscriptions_csv(path: Path) -> dict[str, dict[str, Any]]:
|
|
31
|
+
"""Sidecar file for folder mode. Columns: ItemPath, DeliveryExtension, DataDriven (yes/no)."""
|
|
32
|
+
out: dict[str, dict[str, Any]] = {}
|
|
33
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
34
|
+
for r in csv.DictReader(fh):
|
|
35
|
+
lr = {k.lower(): (v or "") for k, v in r.items()}
|
|
36
|
+
key = lr.get("itempath", "").strip().lower()
|
|
37
|
+
if not key:
|
|
38
|
+
continue
|
|
39
|
+
e = out.setdefault(key, {"count": 0, "data_driven": 0, "non_email": 0})
|
|
40
|
+
e["count"] += 1
|
|
41
|
+
e["data_driven"] += 1 if lr.get("datadriven", "").strip().lower() in ("yes", "true", "1") else 0
|
|
42
|
+
e["non_email"] += 1 if lr.get("deliveryextension", "Report Server Email").strip() not in ("", "Report Server Email") else 0
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unresolved(report: rdl.Report, shared_ds: dict[str, Any], shared_src: dict[str, Any]) -> bool:
|
|
47
|
+
for d in report.datasets:
|
|
48
|
+
if d.shared_dataset_reference and _key(d.shared_dataset_reference) not in shared_ds:
|
|
49
|
+
return True
|
|
50
|
+
for s in report.data_sources:
|
|
51
|
+
if s.shared_reference and _key(s.shared_reference) not in shared_src:
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _key(ref: str) -> str:
|
|
57
|
+
return ref.strip().rstrip("/").split("/")[-1].lower()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dataset_rows(report: rdl.Report, shared_ds: dict[str, rdl.DataSet]) -> list[dict[str, Any]]:
|
|
61
|
+
rows = []
|
|
62
|
+
for d in report.datasets:
|
|
63
|
+
eff = d
|
|
64
|
+
if d.shared_dataset_reference and _key(d.shared_dataset_reference) in shared_ds:
|
|
65
|
+
sd = shared_ds[_key(d.shared_dataset_reference)]
|
|
66
|
+
eff = rdl.DataSet(name=d.name, data_source=sd.data_source, command_type=sd.command_type, command_text=sd.command_text,
|
|
67
|
+
query_parameters=sd.query_parameters or d.query_parameters, field_count=sd.field_count)
|
|
68
|
+
src = next((s for s in report.data_sources if s.name == eff.data_source), None)
|
|
69
|
+
non_sql = bool(src and (src.provider or "").upper() in rdl.NON_SQL_PROVIDERS)
|
|
70
|
+
refs = sqlrefs.Refs(confidence="n/a", error="non-SQL data source") if non_sql else sqlrefs.extract(eff.command_text, eff.command_type)
|
|
71
|
+
rows.append(
|
|
72
|
+
{
|
|
73
|
+
"name": d.name,
|
|
74
|
+
"shared_reference": d.shared_dataset_reference,
|
|
75
|
+
"data_source": eff.data_source,
|
|
76
|
+
"command_type": eff.command_type,
|
|
77
|
+
"command_text": eff.command_text,
|
|
78
|
+
"query_parameters": eff.query_parameters,
|
|
79
|
+
"tables": refs.tables,
|
|
80
|
+
"procedures": refs.procedures,
|
|
81
|
+
"temp_tables": refs.temp_tables,
|
|
82
|
+
"confidence": refs.confidence,
|
|
83
|
+
"parse_error": refs.error,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _report_row(
|
|
90
|
+
report: rdl.Report,
|
|
91
|
+
shared_ds: dict[str, rdl.DataSet],
|
|
92
|
+
shared_src: dict[str, Any],
|
|
93
|
+
usage: UsageData | None,
|
|
94
|
+
subs: dict[str, Any],
|
|
95
|
+
cfg: dict[str, Any],
|
|
96
|
+
linked_from: str | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
datasets = _dataset_rows(report, shared_ds)
|
|
99
|
+
unresolved = _unresolved(report, shared_ds, shared_src)
|
|
100
|
+
failed = [d["name"] for d in datasets if d["confidence"] == "low"]
|
|
101
|
+
cls = classify_mod.classify(report, usage, cfg, subs, unresolved, failed)
|
|
102
|
+
servers = sorted({f"{s.server or '?'}/{s.database or '?'}" for s in report.data_sources if s.server or s.database})
|
|
103
|
+
tables = sorted({t for d in datasets for t in d["tables"]}, key=str.lower)
|
|
104
|
+
procs = sorted({p for d in datasets for p in d["procedures"]}, key=str.lower)
|
|
105
|
+
confidences = {d["confidence"] for d in datasets if d["confidence"] != "n/a"}
|
|
106
|
+
order = ["low", "medium", "high"]
|
|
107
|
+
overall = min(confidences, key=order.index) if confidences else "n/a"
|
|
108
|
+
return {
|
|
109
|
+
"name": report.name,
|
|
110
|
+
"folder": report.folder,
|
|
111
|
+
"path": report.path,
|
|
112
|
+
"file": report.file,
|
|
113
|
+
"rdl_version": report.rdl_version,
|
|
114
|
+
"linked_report_of": linked_from,
|
|
115
|
+
**cls,
|
|
116
|
+
"datasets": datasets,
|
|
117
|
+
"dataset_count": len(datasets),
|
|
118
|
+
"data_sources": [
|
|
119
|
+
{"name": s.name, "shared_reference": s.shared_reference, "provider": s.provider, "server": s.server,
|
|
120
|
+
"database": s.database, "integrated_security": s.integrated_security, "embedded_credentials": s.embedded_credentials}
|
|
121
|
+
for s in report.data_sources
|
|
122
|
+
],
|
|
123
|
+
"data_source_servers": servers,
|
|
124
|
+
"tables": tables,
|
|
125
|
+
"procedures": procs,
|
|
126
|
+
"ref_confidence": overall,
|
|
127
|
+
"parameters": report.parameters,
|
|
128
|
+
"parameter_count": len(report.parameters),
|
|
129
|
+
"subreports": report.subreports,
|
|
130
|
+
"drillthrough": sorted(set(report.drillthrough)),
|
|
131
|
+
"custom_code": report.has_custom_code,
|
|
132
|
+
"custom_assemblies": report.custom_assemblies,
|
|
133
|
+
"embedded_images": report.embedded_images,
|
|
134
|
+
"expressions": report.expressions,
|
|
135
|
+
"complex_expressions": report.complex_expressions,
|
|
136
|
+
"layout": report.layout,
|
|
137
|
+
"embedded_credentials": any(s.embedded_credentials for s in report.data_sources),
|
|
138
|
+
"parse_error": report.parse_error,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def from_folder(
|
|
143
|
+
folder: Path,
|
|
144
|
+
usage: UsageData | None,
|
|
145
|
+
cfg: dict[str, Any],
|
|
146
|
+
subscriptions_csv: Path | None = None,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
folder = folder.resolve()
|
|
149
|
+
reports = [rdl.parse_rdl_file(p, folder) for p in sorted(folder.rglob("*.rdl"))]
|
|
150
|
+
shared_ds: dict[str, rdl.DataSet] = {}
|
|
151
|
+
for p in folder.rglob("*.rsd"):
|
|
152
|
+
d = rdl.parse_shared_dataset_bytes(p.read_bytes())
|
|
153
|
+
if d:
|
|
154
|
+
shared_ds[p.stem.lower()] = d
|
|
155
|
+
shared_src: dict[str, Any] = {}
|
|
156
|
+
for p in folder.rglob("*.rds"):
|
|
157
|
+
s = rdl.parse_shared_datasource_bytes(p.read_bytes())
|
|
158
|
+
if s:
|
|
159
|
+
shared_src[p.stem.lower()] = s
|
|
160
|
+
sidecar = subscriptions_csv or (folder / "subscriptions.csv")
|
|
161
|
+
subs = load_subscriptions_csv(sidecar) if sidecar.exists() else {}
|
|
162
|
+
rows = [_report_row(r, shared_ds, shared_src, usage, subs, cfg) for r in reports]
|
|
163
|
+
return _wrap(rows, usage, "rdl-folder", subs_known=bool(subs))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def from_reportserver(conn: SafeConnection, usage: UsageData | None, cfg: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
catalog = conn.query(Q_CATALOG)
|
|
168
|
+
subs_rows = conn.query(Q_SUBSCRIPTIONS)
|
|
169
|
+
subs = {
|
|
170
|
+
(r["ItemPath"] or "").lower(): {"count": int(r["subs"]), "data_driven": int(r["data_driven"] or 0), "non_email": int(r["non_email"] or 0)}
|
|
171
|
+
for r in subs_rows
|
|
172
|
+
}
|
|
173
|
+
by_id = {r["ItemID"]: r for r in catalog}
|
|
174
|
+
shared_ds: dict[str, rdl.DataSet] = {}
|
|
175
|
+
shared_src: dict[str, Any] = {}
|
|
176
|
+
for r in catalog:
|
|
177
|
+
content = bytes(r["Content"]) if r["Content"] is not None else b""
|
|
178
|
+
if r["Type"] == 8:
|
|
179
|
+
d = rdl.parse_shared_dataset_bytes(content)
|
|
180
|
+
if d:
|
|
181
|
+
shared_ds[_key(r["Path"])] = d
|
|
182
|
+
elif r["Type"] == 5:
|
|
183
|
+
s = rdl.parse_shared_datasource_bytes(content)
|
|
184
|
+
if s:
|
|
185
|
+
shared_src[_key(r["Path"])] = s
|
|
186
|
+
rows: list[dict[str, Any]] = []
|
|
187
|
+
parsed_by_id: dict[Any, rdl.Report] = {}
|
|
188
|
+
for r in catalog:
|
|
189
|
+
if r["Type"] != 2:
|
|
190
|
+
continue
|
|
191
|
+
content = bytes(r["Content"]) if r["Content"] is not None else b""
|
|
192
|
+
path = r["Path"]
|
|
193
|
+
rep = rdl.parse_rdl_bytes(content, path.lstrip("/") + ".rdl", None)
|
|
194
|
+
folder = path.rsplit("/", 1)[0] or "/"
|
|
195
|
+
rep.name, rep.folder, rep.path = r["Name"], folder, path
|
|
196
|
+
parsed_by_id[r["ItemID"]] = rep
|
|
197
|
+
rows.append(_report_row(rep, shared_ds, shared_src, usage, subs, cfg))
|
|
198
|
+
for r in catalog:
|
|
199
|
+
if r["Type"] != 4:
|
|
200
|
+
continue
|
|
201
|
+
src = parsed_by_id.get(r["LinkSourceID"])
|
|
202
|
+
if src is None:
|
|
203
|
+
continue
|
|
204
|
+
path = r["Path"]
|
|
205
|
+
linked = copy.deepcopy(src) # the source definition under the linked report's name
|
|
206
|
+
linked.name, linked.folder, linked.path = r["Name"], path.rsplit("/", 1)[0] or "/", path
|
|
207
|
+
rows.append(_report_row(linked, shared_ds, shared_src, usage, subs, cfg, linked_from=by_id[r["LinkSourceID"]]["Path"]))
|
|
208
|
+
return _wrap(rows, usage, "reportserver", subs_known=True)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _wrap(rows: list[dict[str, Any]], usage: UsageData | None, source: str, subs_known: bool) -> dict[str, Any]:
|
|
212
|
+
rows.sort(key=lambda r: (r["folder"].lower(), r["name"].lower()))
|
|
213
|
+
counts = Counter(r["classification"] for r in rows)
|
|
214
|
+
table_use = Counter(t for r in rows for t in r["tables"])
|
|
215
|
+
return {
|
|
216
|
+
"source": source,
|
|
217
|
+
"report_count": len(rows),
|
|
218
|
+
"usage": {
|
|
219
|
+
"source": usage.source if usage else None,
|
|
220
|
+
"log_window_days": usage.window_days if usage else None,
|
|
221
|
+
"oldest": usage.oldest.strftime("%Y-%m-%d") if usage and usage.oldest else None,
|
|
222
|
+
"newest": usage.newest.strftime("%Y-%m-%d") if usage and usage.newest else None,
|
|
223
|
+
"as_of": usage.as_of.strftime("%Y-%m-%d") if usage else None,
|
|
224
|
+
"note": None if usage else "No usage data supplied. Reports are never classified retire without it.",
|
|
225
|
+
},
|
|
226
|
+
"subscriptions_known": subs_known,
|
|
227
|
+
"classification_counts": dict(counts),
|
|
228
|
+
"table_usage": table_use.most_common(),
|
|
229
|
+
"reports": rows,
|
|
230
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>SQL Server upgrade and SSRS report summary</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --ink:#1b2430; --muted:#5b6675; --line:#d9dfe7; --bg:#ffffff; --panel:#f5f7fa; --accent:#1f3a5f;
|
|
9
|
+
--high:#b3261e; --medium:#a35a00; --low:#4a5a6a; --move:#1d7a46; --review:#a35a00; --retire:#6b4aa0; --pass:#1d7a46; --fail:#b3261e; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: var(--ink); background: var(--bg); margin: 0; }
|
|
12
|
+
main { max-width: 960px; margin: 0 auto; padding: 28px 20px 48px; }
|
|
13
|
+
h1 { font-size: 26px; margin: 0 0 4px; color: var(--accent); }
|
|
14
|
+
h2 { font-size: 19px; margin: 34px 0 10px; padding-bottom: 6px; border-bottom: 2px solid var(--line); color: var(--accent); }
|
|
15
|
+
h3 { font-size: 15px; margin: 18px 0 6px; }
|
|
16
|
+
.sub { color: var(--muted); margin: 0 0 18px; }
|
|
17
|
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 14px 0; }
|
|
18
|
+
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; }
|
|
19
|
+
.tile b { display: block; font-size: 24px; line-height: 1.2; }
|
|
20
|
+
.tile span { color: var(--muted); font-size: 13px; }
|
|
21
|
+
table { border-collapse: collapse; width: 100%; font-size: 13.5px; margin: 8px 0; }
|
|
22
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
23
|
+
th { background: var(--panel); font-weight: 600; }
|
|
24
|
+
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
|
|
25
|
+
.tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; color: #fff; }
|
|
26
|
+
.high,.fail { background: var(--high); } .medium { background: var(--medium); } .low,.skipped,.info { background: var(--low); }
|
|
27
|
+
.move,.pass { background: var(--move); } .review { background: var(--review); } .retire { background: var(--retire); }
|
|
28
|
+
.bar { display: flex; height: 22px; border-radius: 6px; overflow: hidden; margin: 8px 0; border: 1px solid var(--line); }
|
|
29
|
+
.bar i { display: block; height: 100%; }
|
|
30
|
+
.note { background: #fff7e6; border: 1px solid #f0d49b; border-radius: 8px; padding: 10px 12px; margin: 12px 0; }
|
|
31
|
+
.legend span { margin-right: 14px; font-size: 13px; }
|
|
32
|
+
ol.steps li { margin: 4px 0; }
|
|
33
|
+
a { color: var(--accent); }
|
|
34
|
+
footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; }
|
|
35
|
+
.small { color: var(--muted); font-size: 12.5px; }
|
|
36
|
+
@media print { main { max-width: none; padding: 0; } h2 { break-after: avoid; } table, .tile { break-inside: avoid; } a { color: inherit; text-decoration: none; } }
|
|
37
|
+
</style>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<main>
|
|
41
|
+
<h1>SQL Server upgrade and SSRS report summary</h1>
|
|
42
|
+
<p class="sub">Generated {{ s.generated }} by db-agent {{ s.version }}. Read-only: nothing was changed on any server.</p>
|
|
43
|
+
|
|
44
|
+
<h2>Executive summary</h2>
|
|
45
|
+
<div class="tiles">
|
|
46
|
+
{% if s.have_inventory %}
|
|
47
|
+
<div class="tile"><b>{{ s.server.product_version }}</b><span>{{ s.server.edition }}</span></div>
|
|
48
|
+
<div class="tile"><b>{{ s.eos.date or 'not in table' }}</b><span>{{ s.eos.name }} end of support{% if s.eos.days is not none %}, {% if s.eos.days < 0 %}ended {{ -s.eos.days }} days ago{% else %}{{ s.eos.days }} days left{% endif %}{% endif %}</span></div>
|
|
49
|
+
<div class="tile"><b>{{ s.db_count }}</b><span>user databases, {{ s.db_size_gb }} GB</span></div>
|
|
50
|
+
<div class="tile"><b>{{ s.risk_counts.get('high', 0) }} / {{ s.risk_counts.get('medium', 0) }} / {{ s.risk_counts.get('low', 0) }}</b><span>risks: high / medium / low</span></div>
|
|
51
|
+
{% endif %}
|
|
52
|
+
{% if s.have_ssrs %}
|
|
53
|
+
<div class="tile"><b>{{ s.ssrs.total }}</b><span>SSRS reports</span></div>
|
|
54
|
+
<div class="tile"><b>{{ s.ssrs.used }} / {{ s.ssrs.unused }}</b><span>used / not used (365 days)</span></div>
|
|
55
|
+
<div class="tile"><b>{{ s.ssrs.counts.get('move', 0) }} / {{ s.ssrs.counts.get('review', 0) }} / {{ s.ssrs.counts.get('retire', 0) }}</b><span>move / review / retire</span></div>
|
|
56
|
+
{% endif %}
|
|
57
|
+
{% if s.have_validation %}
|
|
58
|
+
<div class="tile"><b>{{ s.validation.counts.get('pass', 0) }} / {{ s.validation.counts.get('fail', 0) }} / {{ s.validation.counts.get('skipped', 0) }}</b><span>validation pass / fail / skipped</span></div>
|
|
59
|
+
{% endif %}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<h3>Suggested next steps</h3>
|
|
63
|
+
<ol class="steps">{% for t in s.next_steps %}<li>{{ t }}</li>{% endfor %}</ol>
|
|
64
|
+
|
|
65
|
+
{% if s.have_inventory %}
|
|
66
|
+
<h2>Databases</h2>
|
|
67
|
+
<table>
|
|
68
|
+
<tr><th>Database</th><th class="n">Compat level</th><th class="n">Data MB</th><th class="n">Log MB</th><th>Recovery</th></tr>
|
|
69
|
+
{% for d in s.databases %}<tr><td>{{ d.name }}</td><td class="n">{{ d.compatibility_level }}</td><td class="n">{{ d.data_mb }}</td><td class="n">{{ d.log_mb }}</td><td>{{ d.recovery_model_desc }}</td></tr>{% endfor %}
|
|
70
|
+
</table>
|
|
71
|
+
|
|
72
|
+
<h2>Top risks</h2>
|
|
73
|
+
<table>
|
|
74
|
+
<tr><th>Severity</th><th>Finding</th><th class="n">Count</th><th>Example</th><th>Source</th></tr>
|
|
75
|
+
{% for r in s.top_risks %}<tr><td><span class="tag {{ r.severity }}">{{ r.severity }}</span></td><td>{{ r.title }}<div class="small">{{ r.detail }}</div></td><td class="n">{{ r.count }}</td><td>{{ r.example }}</td><td><a href="{{ r.doc }}">Microsoft docs</a></td></tr>{% endfor %}
|
|
76
|
+
</table>
|
|
77
|
+
<p class="small">Every risk is in risks.csv. Rules come from Microsoft's deprecated and discontinued feature lists. Dynamic SQL held in strings is not scanned.</p>
|
|
78
|
+
{% if s.warnings %}<div class="note"><b>Not collected:</b> {{ s.warnings | length }} item(s), for example missing permissions. See inventory.md.</div>{% endif %}
|
|
79
|
+
{% endif %}
|
|
80
|
+
|
|
81
|
+
{% if s.have_ssrs %}
|
|
82
|
+
<h2>SSRS reports</h2>
|
|
83
|
+
{% set c = s.ssrs.counts %}{% set t = s.ssrs.total or 1 %}
|
|
84
|
+
<div class="bar">
|
|
85
|
+
<i style="width:{{ 100 * c.get('move', 0) / t }}%; background:var(--move)"></i>
|
|
86
|
+
<i style="width:{{ 100 * c.get('review', 0) / t }}%; background:var(--review)"></i>
|
|
87
|
+
<i style="width:{{ 100 * c.get('retire', 0) / t }}%; background:var(--retire)"></i>
|
|
88
|
+
</div>
|
|
89
|
+
<p class="legend"><span><span class="tag move">move</span> {{ c.get('move', 0) }}</span><span><span class="tag review">review</span> {{ c.get('review', 0) }}</span><span><span class="tag retire">retire</span> {{ c.get('retire', 0) }}</span></p>
|
|
90
|
+
{% if s.ssrs.usage.source %}
|
|
91
|
+
<p>Usage comes from {{ s.ssrs.usage.source }}. The log reaches back {{ s.ssrs.usage.log_window_days }} days (oldest entry {{ s.ssrs.usage.oldest }}).</p>
|
|
92
|
+
{% if (s.ssrs.usage.log_window_days or 0) < 365 %}<div class="note"><b>The usage log is shorter than a year.</b> SSRS keeps 60 days by default, so a quarterly or yearly report can look unused. No report is marked retire until the log covers 365 days.</div>{% endif %}
|
|
93
|
+
{% else %}<div class="note"><b>No usage data.</b> Without it, no report is marked retire.</div>{% endif %}
|
|
94
|
+
|
|
95
|
+
<h3>Hard-to-move features</h3>
|
|
96
|
+
<table>
|
|
97
|
+
<tr><th>Feature</th><th class="n">Reports</th></tr>
|
|
98
|
+
<tr><td>Custom code blocks</td><td class="n">{{ s.ssrs.hard.custom_code }}</td></tr>
|
|
99
|
+
<tr><td>Custom assemblies</td><td class="n">{{ s.ssrs.hard.custom_assemblies }}</td></tr>
|
|
100
|
+
<tr><td>Subreports</td><td class="n">{{ s.ssrs.hard.subreports }}</td></tr>
|
|
101
|
+
<tr><td>Drillthrough</td><td class="n">{{ s.ssrs.hard.drillthrough }}</td></tr>
|
|
102
|
+
<tr><td>Subscriptions{% if not s.ssrs.subscriptions_known %} (not read){% endif %}</td><td class="n">{{ s.ssrs.hard.subscriptions }}</td></tr>
|
|
103
|
+
<tr><td>SQL that could not be fully parsed</td><td class="n">{{ s.ssrs.hard.parse_problems }}</td></tr>
|
|
104
|
+
</table>
|
|
105
|
+
<p class="small">Layout hint only: {{ s.ssrs.paginated_hint }} look like printable tabular reports (suit Power BI paginated reports); {{ s.ssrs.interactive_hint }} use charts, gauges, maps or show/hide toggles (likely an interactive rebuild).</p>
|
|
106
|
+
|
|
107
|
+
<h3>Top 20 reports by use (last 365 days)</h3>
|
|
108
|
+
<table>
|
|
109
|
+
<tr><th>Report</th><th class="n">Runs 365d</th><th class="n">Runs 90d</th><th class="n">Users</th><th>Last run</th><th>Class</th></tr>
|
|
110
|
+
{% for r in s.ssrs.top_used %}<tr><td>{{ r.path }}</td><td class="n">{{ r.runs_365 }}</td><td class="n">{{ r.runs_90 }}</td><td class="n">{{ r.distinct_users }}</td><td>{{ r.last_run }}</td><td><span class="tag {{ r.classification }}">{{ r.classification }}</span></td></tr>
|
|
111
|
+
{% else %}<tr><td colspan="6">No usage data.</td></tr>{% endfor %}
|
|
112
|
+
</table>
|
|
113
|
+
|
|
114
|
+
<h3>Tables used most by reports</h3>
|
|
115
|
+
<table>
|
|
116
|
+
<tr><th>Table</th><th class="n">Reports</th></tr>
|
|
117
|
+
{% for tbl, n in s.ssrs.tables %}<tr><td>{{ tbl }}</td><td class="n">{{ n }}</td></tr>{% endfor %}
|
|
118
|
+
</table>
|
|
119
|
+
<p class="small">Best effort from parsing report SQL. Reports that call stored procedures list the procedure, not its tables.</p>
|
|
120
|
+
|
|
121
|
+
{% if s.ssrs.retire %}<h3>Retire candidates</h3>
|
|
122
|
+
<table><tr><th>Report</th><th>Reason</th></tr>
|
|
123
|
+
{% for r in s.ssrs.retire %}<tr><td>{{ r.path }}</td><td>{{ r.reasons | join('; ') }}</td></tr>{% endfor %}</table>{% endif %}
|
|
124
|
+
|
|
125
|
+
{% if s.ssrs.review %}<h3>Needs review</h3>
|
|
126
|
+
<table><tr><th>Report</th><th>Reason</th></tr>
|
|
127
|
+
{% for r in s.ssrs.review %}<tr><td>{{ r.path }}</td><td>{{ r.reasons | join('; ') }}</td></tr>{% endfor %}</table>{% endif %}
|
|
128
|
+
{% endif %}
|
|
129
|
+
|
|
130
|
+
{% if s.have_validation %}
|
|
131
|
+
<h2>Validation after the move</h2>
|
|
132
|
+
<p>Row counts and totals of numeric columns were compared between the old and new server. No row values are stored.</p>
|
|
133
|
+
<table>
|
|
134
|
+
<tr><th>Report</th><th>Dataset</th><th>Status</th><th class="n">Source rows</th><th class="n">Target rows</th><th>Note</th></tr>
|
|
135
|
+
{% for v in s.validation.rows %}<tr><td>{{ v.report }}</td><td>{{ v.dataset }}</td><td><span class="tag {{ v.status }}">{{ v.status }}</span></td><td class="n">{{ v.source_rows if v.source_rows is not none else '' }}</td><td class="n">{{ v.target_rows if v.target_rows is not none else '' }}</td><td>{{ v.differences or v.reason }}</td></tr>{% endfor %}
|
|
136
|
+
</table>
|
|
137
|
+
{% endif %}
|
|
138
|
+
|
|
139
|
+
<footer>{{ s.footer }}</footer>
|
|
140
|
+
</main>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|