Compare commits

..

No commits in common. "cb2f8fcbcebcaa5f362f54b268c8591d543e2cc1" and "c2cdb58a27856faadb47d6284cd8be3ba1f111c1" have entirely different histories.

145 changed files with 221 additions and 8439 deletions

View file

@ -14,7 +14,7 @@
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
<!-- Release version; set with -p:Version=… when publishing. Update checks compare against it. -->
<Version>1.1.0</Version>
<Version>1.0.4</Version>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">

View file

@ -22,13 +22,11 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="10.0.12" />
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />
<PackageVersion Include="Markdig" Version="1.4.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Fase 1–4 er implementeret: agent, 66 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
> **Status:** Fase 1–4 er implementeret: agent, 61 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
---
@ -205,7 +205,6 @@ Hvert **finding** indeholder:
### Fase 4: Videre
- [x] Anbefalinger på tværs af kunder: Hubben ser, hvilke fund der forsvinder efter en opdatering, og agenterne viser "hos 4 kunder forsvandt dette efter CU21" på fundet. Deles kun samlet og anonymt fra mindst 3 kunder. Se [docs/hub.md](docs/hub.md#erfaring-på-tværs-af-kunder)
- [x] Rapporter (PDF) til ledelse og compliance: nøgletal, sammenfatning i almindeligt sprog, kontroller, instanser og alvorlige fund. Kan hentes i webbrugerfladen og sendes månedligt på mail. Se [docs/rapporter.md](docs/rapporter.md)
- [x] Rettighedsrevision: sysadmin og øvrige serverroller med Windows-grupper foldet ud via AD, ændringer med hvem/hvornår fra default trace, fund ved nye medlemmer og revisionsrapport (PDF). Se [docs/rettigheder.md](docs/rettigheder.md)
- [x] Mulighed for, at community selv bidrager med regler: regler som SQL i JSON (community via pull request og signeret regelpakke, lokale regler hos kunden), med validering og `rules test` mod en instans. Se [docs/regler.md](docs/regler.md)
- [x] API til integration med eksisterende monitorering: JSON-API og Prometheus-endpoint med API-tokens, Grafana-dashboard, og Windows' hændelseslog med faste event-id'er til SCOM. Se [docs/integration.md](docs/integration.md)
- [x] Community-websted (download af signerede udgivelser, dokumentation, SQL Server-versioner, regler, support) og Discourse-forum på dansk med kategorier, løste svar og opsætning som kode. Se [docs/community.md](docs/community.md)

View file

@ -105,14 +105,4 @@ SET @Sql = N'USE msdb;
GRANT SELECT ON dbo.syscategories TO ' + @Quoted + N';';
EXEC (@Sql);
/*
Valgfrit: hvem og hvornår ved ændringer af serverroller.
Rettighedsrevisionen (sysadmin m.m.) virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise,
HVEM der gav en ny rettighed og HVORNÅR. ALTER TRACE giver også adgang til at oprette traces, så det er et bevidst
valg; fjern kommentaren for at give den:
SET @Sql = N'GRANT ALTER TRACE TO ' + @Quoted + N';';
EXEC (@Sql);
*/
PRINT N'Rettigheder givet til ' + @LoginName + N'.';

View file

@ -13,7 +13,6 @@ Angiv fra og til (i webbrugerfladens tidszone, `SQLInsight:Web:TimeZone`), eller
| Belastning | Forløb af CPU (SQL Server og andre processer), batch requests og compilations pr. sekund, page life expectancy, brugerforbindelser, memory grants pending og blokerede processer |
| Største ventetider | Ventetid pr. minut i tidsrummet, sammenlignet med normalen. Harmløse baggrundsventetider er udeladt |
| Tungeste forespørgsler | De forespørgsler, der brugte mest CPU, tid, læsninger eller blev kørt flest gange i tidsrummet, med tekst og database |
| Diskhastighed pr. drev | Læse- og skrive-IOPS, MB/s og gennemsnitlig latens pr. drev, sammenlignet med normalen (se nedenfor) |
| I/O-latens pr. fil | Læsninger, skrivninger og gennemsnitlig latens pr. databasefil |
| Blocking og deadlocks | Blocking-kæder over 5 sekunder (head blocker, antal blokerede, længste ventetid) og deadlocks med graf (.xdl) |
| Fund | Fund, der var aktive på et tidspunkt i tidsrummet |
@ -27,22 +26,11 @@ Kun tidsrum, hvor agenten kørte og data stadig er gemt:
| Data | Opbevaring (standard, `SQLInsight:Retention`) |
|---|---|
| Målinger hvert 5. minut: belastning, ventetider, I/O, forespørgsler | 14 dage (`RawDays`) |
| Timeværdier: belastning, ventetider og diskhastighed pr. drev | 400 dage (`HourlyDays`) |
| Timeværdier: belastning og ventetider | 400 dage (`HourlyDays`) |
| Blocking og deadlocks | 90 dage (`EventDays`) |
Ligger tidsrummet længere tilbage end `RawDays`, bruges timeværdier. Det står på siden. Så er der ingen I/O pr. fil og ingen forespørgsler, men belastning og ventetider kan stadig sammenlignes.
## Diskhastighed pr. drev
Agenten læser hvert 5. minut, hvor meget hver databasefil har læst og skrevet (`sys.dm_io_virtual_file_stats`: antal I/O'er, bytes og ventetid), og lægger filerne sammen pr. drev:
- **IOPS** = læsninger eller skrivninger pr. sekund, **MB/s** = data pr. sekund, **latens** = gennemsnitlig ventetid pr. læsning eller skrivning, vægtet efter antal I/O'er.
- Drevet kommer fra `sys.dm_os_volume_stats` (SQL Server 2008 R2 SP1 og nyere), så mount points vises rigtigt. På ældre versioner udledes det af filens sti (`D:\…` → `D:\`), og et mount point under et drevbogstav tælles da med drevet.
- Det er tal set fra SQL Server: kun SQL Servers egne filer, ikke andre programmer på samme drev.
- Instanssiden viser de seneste 24 timer med graf over MB/s og latens pr. drev. Fundene SQLI-IO-001 (læselatens på datafiler) og SQLI-IO-002 (skrivelatens på logfiler) gælder pr. drev, med de langsomste filer som målinger.
Et drev markeres med rødt, når datafiler har over 20 ms pr. læsning, logfiler over 5 ms pr. skrivning, eller latensen er mindst dobbelt så høj som normalt.
## Tungeste forespørgsler
Agenten læser `sys.dm_exec_query_stats` hvert 5. minut (collector `query-stats`, kan ændres med `SQLInsight:CollectorIntervals`) og gemmer, hvad hver forespørgsel brugte siden sidst:
@ -55,53 +43,3 @@ Agenten læser `sys.dm_exec_query_stats` hvert 5. minut (collector `query-stats`
Rettigheden er `VIEW SERVER STATE` fra `grant-least-privilege.sql`. SQL Server 2000 har ikke DMV'en, så her er der ingen forespørgselsstatistik.
**Forespørgselstekst er kundedata.** Den gemmes kun i kundens eget repository, vises kun i webbrugerfladen og i PDF'en hos kunden, og sendes aldrig til SQLInsight Hub. Se [databehandling.md](databehandling.md).
## Ventetyper: hvad venter på hvad
Klik på en ventetype på instanssiden eller i analysen for at åbne `/instanser/{navn}/ventetyper/{ventetype}?fra=&til=`. Siden viser:
- **Hvad betyder det?** En dansk forklaring: hvad ventetypen betyder, hvornår den er normal, typiske årsager og hvad man kan gøre, med noter for Dynamics AX hvor det er relevant (fx MAXDOP 1 ved CXPACKET). Teksterne er de samme som i ventetidsreglerne (SQLI-WAIT-*). Ventetyper uden dansk forklaring får en generel tekst med kategorien og et link til Microsofts liste.
- **Udvikling**: ventetid pr. minut over tid (hvert 5. minut inden for `RawDays`, ellers timeværdier).
- **Hvilke sætninger venter**: de sætninger, sessionerne ventede i, med antal stikprøver, andel, anslået ventetid (stikprøver × 15 s), gennemsnitlig ventet tid, database, et uddrag af teksten og et link til hele teksten.
- For låseventetider (LCK_M_*) et link til blocking i samme tidsrum.
### Stikprøver hvert 15. sekund
Ventetidsstatistikken (`sys.dm_os_wait_stats`) siger hvor meget der ventes, men ikke på hvad. Derfor tager agenten hvert 15. sekund en stikprøve af `sys.dm_exec_requests` (som Oracles ASH): for hver brugersession, der venter, gemmes ventetype, ventet tid, database, sætningens nøgle (den samme som for tungeste forespørgsler, så teksten deles) og om den er blokeret. Agentens egne sessioner (program "SQLInsight Pro…") og systemsessioner tages ikke med, og det samme gælder harmløse baggrundsventetider.
- Stikprøverne summeres pr. ventetype, database og sætning og gemmes i `insight.ActiveWaitSamples` i `RawDays` (standard 14 dage).
- En stikprøve er én let forespørgsel; agenten genkender instansen fra seneste fulde gennemløb og evaluerer ikke regler for stikprøverne alene.
- Ventetider, der varer under 15 sekunder i alt, kan falde mellem to stikprøver. Tallene er et billede af, hvor ventetiden ligger, ikke en præcis måling.
- Virker fra SQL Server 2005 (her er nøglen batch og position, fordi `query_hash` først kom i 2008). Intervallet kan ændres med `SQLInsight:CollectorIntervals:wait-sampling`.
- Sætningernes tekst er kundens data og sendes aldrig til SQLInsight Hub.
## Hvad betyder målingerne?
Under hver graf på instanssiden og i analysen står en vurdering i farve – **OK** (grøn), **Bemærk** (gul) eller **Reagér** (rød) – med et konkret næste skridt og et link, og en fold-ud "Hvad betyder det?" med en forklaring i almindeligt sprog. Samme vurdering står i PDF'en. Reglerne:
| Måling | Bemærk | Reagér |
|---|---|---|
| CPU (SQL Server + andre) | Gns. over 60 % | Over 80 % i mindst 3 målinger i træk; peger på andre processer, hvis de bruger over 20 % eller mere end SQL Server |
| CPU brugt af andre processer | Gns. 10 % eller mere | Over 30 % vedvarende, eller gns. 20 % og mere end SQL Server |
| Page life expectancy | Laveste værdi under grænsen, et fald på over 50 % undervejs, eller max server memory ikke sat | Gennemsnit under grænsen: 300 s pr. 4 GB buffer pool (max server memory, ellers serverens hukommelse) |
| SQL compilations | Over 10 % af batch requests | Over 20 % af batch requests (kun ved mindst 5 batches/s) |
| Memory grants pending, blokerede processer | Over 0 kortvarigt | Over 0 i mindst 3 målinger i træk |
| Brugerforbindelser | Højeste over dobbelt gennemsnit | – |
| Batch requests | Altid OK: et mål for travlhed | – |
| Disk, læsning fra datafiler | Over 10 ms | Over 20 ms |
| Disk, skrivning til datafiler | Over 10 ms | Over 20 ms |
| Disk, skrivning til log (drev kun med logfiler) | Over 5 ms | Over 15 ms |
Diske vurderes kun med mindst 100 læsninger/skrivninger i perioden. Logikken ligger ét sted (`MetricAssessment`), så siden og PDF'en siger det samme.
## Langvarige operationer
Hvert minut læser agenten de operationer, SQL Server viser fremskridt for (`percent_complete` i `sys.dm_exec_requests`): BACKUP, RESTORE, DBCC CHECKDB, ALTER INDEX REORGANIZE, SHRINK, ROLLBACK m.fl. med kommando, database, start, % færdig, forventet sluttid og login. De vises under **Igangværende og nylige langvarige operationer** på instanssiden og i analysen af et tidsrum, og gemmes i `RawDays`. Login er følsomt og vises kun lokalt.
Agenten husker varigheden af de seneste 10 kørsler af samme kommando på samme database (kørsler under et minut tæller ikke). Kører en backup eller restore mere end dobbelt så længe som medianen (og mindst 5 minutter længere), giver det fundet **SQLI-OPS-001** (Middel).
## Aktivitet: sætninger, der kører næsten uafbrudt
Kolonnen **Aktivitet** under tungeste forespørgsler er sætningens CPU-tid som andel af tidsrummet: 100 % betyder, at den har holdt én CPU-kerne beskæftiget hele tiden (over 100 % er parallelt).
Reglen **SQLI-CPU-002** (Middel) ser på sætninger, der har ligget i plan cache i mindst en time og stadig kører: er deres samlede CPU-tid over 50 % af tiden i cache, bruger de i praksis en CPU-kerne konstant. Grænsen sættes med `SQLInsight:TopSql:ActivityThresholdPercent` (standard 50). SQLInsight tømmer aldrig plan cache eller ændrer noget; fundet peger kun på sætningen.

View file

@ -98,5 +98,3 @@ Deling er slået fra som standard og er ikke en betingelse for Professional elle
## Kundens egen installation
Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Til [analyse af tidsrum](analyse.md) gemmes desuden de tungeste forespørgsler med tekst og forbrug hvert 5. minut i `RawDays` (standard 14 dage); den tekst bruges kun lokalt og indgår aldrig i det, der sendes til SQLInsight Hub. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder.
Til [rettighedsrevisionen](rettigheder.md) gemmes navne på personer og konti med forhøjede serverrettigheder (også personer i Windows-grupper, slået op i kundens Active Directory af agenten), hvem der har fået eller mistet dem, og hvem der foretog ændringen. Det er personoplysninger. De bliver i kundens repository: ændringsloggen slettes efter `ResolvedFindingDays` (standard 365 dage), og i fund er navnene følsomme målinger, som sendes til SQLInsight Hub og integrations-API'et som `[udeladt]`. Fundets titel og emne indeholder ingen navne. Revisionsrapporten (PDF) dannes kun lokalt.

View file

@ -1,127 +0,0 @@
skema tabel kolonne type max_length column_id
bmssa BATCH PRIORITY int 4 1
bmssa BATCH GROUPID varchar 10 2
bmssa BATCH PRIVATE int 4 3
bmssa BATCH STARTDATE datetime 8 4
bmssa BATCH STARTTIME int 4 5
bmssa BATCH ENDDATE datetime 8 6
bmssa BATCH ENDTIME int 4 7
bmssa BATCH EXECUTEDBY varchar 5 8
bmssa BATCH CLASSNUM int 4 9
bmssa BATCH COMPANY varchar 3 10
bmssa BATCH DEL_PER int 4 11
bmssa BATCH ORIGSTARTDATE datetime 8 12
bmssa BATCH ORIGSTARTTIME int 4 13
bmssa BATCH PARAMETERS image 16 14
bmssa BATCH STATUS int 4 15
bmssa BATCH DEL_PERUNIT int 4 16
bmssa BATCH EXCEPTIONCODE int 4 17
bmssa BATCH INFO image 16 18
bmssa BATCH JOURNALID varchar 10 19
bmssa BATCH SESSIONIDX int 4 20
bmssa BATCH SESSIONLOGINDATE datetime 8 21
bmssa BATCH SESSIONLOGINTIME int 4 22
bmssa BATCH ORIGRECID int 4 23
bmssa BATCH EMAIL int 4 24
bmssa BATCH ORIGCREATEDBY varchar 5 25
bmssa BATCH REUSED int 4 26
bmssa BATCH RECURRENCEDATA image 16 27
bmssa BATCH CREATEDDATE datetime 8 28
bmssa BATCH CREATEDTIME int 4 29
bmssa BATCH CREATEDBY varchar 5 30
bmssa BATCH DATAAREAID varchar 3 31
bmssa BATCH RECVERSION int 4 32
bmssa BATCH RECID int 4 33
bmssa BATCHGROUP GROUP_ varchar 10 1
bmssa BATCHGROUP DESCRIPTION varchar 30 2
bmssa BATCHGROUP DATAAREAID varchar 3 3
bmssa BATCHGROUP RECVERSION int 4 4
bmssa BATCHGROUP RECID int 4 5
bmssa DATAAREA ID varchar 3 1
bmssa DATAAREA NAME varchar 40 2
bmssa DATAAREA ISVIRTUAL int 4 3
bmssa DATAAREA ALWAYSNATIVE int 4 4
bmssa DATAAREA RECVERSION int 4 5
bmssa DATAAREA RECID int 4 6
bmssa NUMBERSEQUENCEREFERENCE NUMBERSEQUENCE varchar 10 1
bmssa NUMBERSEQUENCEREFERENCE DATATYPEID int 4 2
bmssa NUMBERSEQUENCEREFERENCE CONFIGURATIONKEYID int 4 3
bmssa NUMBERSEQUENCEREFERENCE MODULE int 4 4
bmssa NUMBERSEQUENCEREFERENCE REFERENCEHELP varchar 255 5
bmssa NUMBERSEQUENCEREFERENCE REFERENCELABEL varchar 30 6
bmssa NUMBERSEQUENCEREFERENCE DATATYPESAMEASID int 4 7
bmssa NUMBERSEQUENCEREFERENCE DEL_WIZARDRESTRICTIONLEVEL int 4 8
bmssa NUMBERSEQUENCEREFERENCE WIZARDCONTINUOUS int 4 9
bmssa NUMBERSEQUENCEREFERENCE ALLOWSAMEAS int 4 10
bmssa NUMBERSEQUENCEREFERENCE SORTFIELD int 4 11
bmssa NUMBERSEQUENCEREFERENCE GROUPENABLED int 4 12
bmssa NUMBERSEQUENCEREFERENCE WIZARDHIGHEST int 4 13
bmssa NUMBERSEQUENCEREFERENCE WIZARDLOWEST int 4 14
bmssa NUMBERSEQUENCEREFERENCE WIZARDALLOWCHANGEDOWN int 4 15
bmssa NUMBERSEQUENCEREFERENCE WIZARDALLOWCHANGEUP int 4 16
bmssa NUMBERSEQUENCEREFERENCE WIZARDMANUAL int 4 17
bmssa NUMBERSEQUENCEREFERENCE WIZARDFETCHAHEADQTY int 4 18
bmssa NUMBERSEQUENCEREFERENCE DEL_LEDGERPOSTINGJOURNALID varchar 10 19
bmssa NUMBERSEQUENCEREFERENCE DATAAREAID varchar 3 20
bmssa NUMBERSEQUENCEREFERENCE RECVERSION int 4 21
bmssa NUMBERSEQUENCEREFERENCE RECID int 4 22
bmssa NUMBERSEQUENCETABLE NUMBERSEQUENCE varchar 10 1
bmssa NUMBERSEQUENCETABLE TXT varchar 35 2
bmssa NUMBERSEQUENCETABLE LOWEST int 4 3
bmssa NUMBERSEQUENCETABLE HIGHEST int 4 4
bmssa NUMBERSEQUENCETABLE NEXTREC int 4 5
bmssa NUMBERSEQUENCETABLE BLOCKED int 4 6
bmssa NUMBERSEQUENCETABLE FORMAT varchar 20 7
bmssa NUMBERSEQUENCETABLE CONTINUOUS int 4 8
bmssa NUMBERSEQUENCETABLE CYCLIC int 4 9
bmssa NUMBERSEQUENCETABLE CLEANATACCESS int 4 10
bmssa NUMBERSEQUENCETABLE INUSE int 4 11
bmssa NUMBERSEQUENCETABLE NOINCREMENT int 4 12
bmssa NUMBERSEQUENCETABLE DEL_RESTRICTIONLEVEL int 4 13
bmssa NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 14
bmssa NUMBERSEQUENCETABLE LATESTCLEANDATE datetime 8 15
bmssa NUMBERSEQUENCETABLE LATESTCLEANTIME int 4 16
bmssa NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 17
bmssa NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 18
bmssa NUMBERSEQUENCETABLE MANUAL int 4 19
bmssa NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 20
bmssa NUMBERSEQUENCETABLE FETCHAHEAD int 4 21
bmssa NUMBERSEQUENCETABLE DEL_FORCEDATEORDER int 4 22
bmssa NUMBERSEQUENCETABLE MODIFIEDTRANSACTIONID int 4 23
bmssa NUMBERSEQUENCETABLE DATAAREAID varchar 3 24
bmssa NUMBERSEQUENCETABLE RECVERSION int 4 25
bmssa NUMBERSEQUENCETABLE RECID int 4 26
bmssa SQLDICTIONARY TABLEID int 4 1
bmssa SQLDICTIONARY FIELDID int 4 2
bmssa SQLDICTIONARY ARRAY int 4 3
bmssa SQLDICTIONARY NAME varchar 40 4
bmssa SQLDICTIONARY SQLNAME varchar 40 5
bmssa SQLDICTIONARY FIELDTYPE int 4 6
bmssa SQLDICTIONARY STRSIZE int 4 7
bmssa SQLDICTIONARY SHADOW int 4 8
bmssa SQLDICTIONARY RIGHTJUSTIFY int 4 9
bmssa SQLDICTIONARY NULLABLE int 4 10
bmssa SQLDICTIONARY FLAGS int 4 11
bmssa SQLDICTIONARY RECVERSION int 4 12
bmssa SQLDICTIONARY RECID int 4 13
bmssa SYSCONFIG CONFIGTYPE int 4 1
bmssa SYSCONFIG ID int 4 2
bmssa SYSCONFIG VALUE varchar 80 3
bmssa SYSCONFIG MODIFIEDDATE datetime 8 4
bmssa SYSCONFIG MODIFIEDTIME int 4 5
bmssa SYSCONFIG MODIFIEDBY varchar 5 6
bmssa SYSCONFIG CREATEDDATE datetime 8 7
bmssa SYSCONFIG CREATEDTIME int 4 8
bmssa SYSCONFIG CREATEDBY varchar 5 9
bmssa SYSCONFIG RECVERSION int 4 10
bmssa SYSCONFIG RECID int 4 11
bmssa SYSTEMSEQUENCES ID int 4 1
bmssa SYSTEMSEQUENCES NEXTVAL int 4 2
bmssa SYSTEMSEQUENCES MINVAL int 4 3
bmssa SYSTEMSEQUENCES MAXVAL int 4 4
bmssa SYSTEMSEQUENCES CYCLE int 4 5
bmssa SYSTEMSEQUENCES NAME varchar 20 6
bmssa SYSTEMSEQUENCES TABID int 4 7
bmssa SYSTEMSEQUENCES DATAAREAID varchar 3 8
bmssa SYSTEMSEQUENCES RECVERSION int 4 9
bmssa SYSTEMSEQUENCES RECID int 4 10
1 skema tabel kolonne type max_length column_id
2 bmssa BATCH PRIORITY int 4 1
3 bmssa BATCH GROUPID varchar 10 2
4 bmssa BATCH PRIVATE int 4 3
5 bmssa BATCH STARTDATE datetime 8 4
6 bmssa BATCH STARTTIME int 4 5
7 bmssa BATCH ENDDATE datetime 8 6
8 bmssa BATCH ENDTIME int 4 7
9 bmssa BATCH EXECUTEDBY varchar 5 8
10 bmssa BATCH CLASSNUM int 4 9
11 bmssa BATCH COMPANY varchar 3 10
12 bmssa BATCH DEL_PER int 4 11
13 bmssa BATCH ORIGSTARTDATE datetime 8 12
14 bmssa BATCH ORIGSTARTTIME int 4 13
15 bmssa BATCH PARAMETERS image 16 14
16 bmssa BATCH STATUS int 4 15
17 bmssa BATCH DEL_PERUNIT int 4 16
18 bmssa BATCH EXCEPTIONCODE int 4 17
19 bmssa BATCH INFO image 16 18
20 bmssa BATCH JOURNALID varchar 10 19
21 bmssa BATCH SESSIONIDX int 4 20
22 bmssa BATCH SESSIONLOGINDATE datetime 8 21
23 bmssa BATCH SESSIONLOGINTIME int 4 22
24 bmssa BATCH ORIGRECID int 4 23
25 bmssa BATCH EMAIL int 4 24
26 bmssa BATCH ORIGCREATEDBY varchar 5 25
27 bmssa BATCH REUSED int 4 26
28 bmssa BATCH RECURRENCEDATA image 16 27
29 bmssa BATCH CREATEDDATE datetime 8 28
30 bmssa BATCH CREATEDTIME int 4 29
31 bmssa BATCH CREATEDBY varchar 5 30
32 bmssa BATCH DATAAREAID varchar 3 31
33 bmssa BATCH RECVERSION int 4 32
34 bmssa BATCH RECID int 4 33
35 bmssa BATCHGROUP GROUP_ varchar 10 1
36 bmssa BATCHGROUP DESCRIPTION varchar 30 2
37 bmssa BATCHGROUP DATAAREAID varchar 3 3
38 bmssa BATCHGROUP RECVERSION int 4 4
39 bmssa BATCHGROUP RECID int 4 5
40 bmssa DATAAREA ID varchar 3 1
41 bmssa DATAAREA NAME varchar 40 2
42 bmssa DATAAREA ISVIRTUAL int 4 3
43 bmssa DATAAREA ALWAYSNATIVE int 4 4
44 bmssa DATAAREA RECVERSION int 4 5
45 bmssa DATAAREA RECID int 4 6
46 bmssa NUMBERSEQUENCEREFERENCE NUMBERSEQUENCE varchar 10 1
47 bmssa NUMBERSEQUENCEREFERENCE DATATYPEID int 4 2
48 bmssa NUMBERSEQUENCEREFERENCE CONFIGURATIONKEYID int 4 3
49 bmssa NUMBERSEQUENCEREFERENCE MODULE int 4 4
50 bmssa NUMBERSEQUENCEREFERENCE REFERENCEHELP varchar 255 5
51 bmssa NUMBERSEQUENCEREFERENCE REFERENCELABEL varchar 30 6
52 bmssa NUMBERSEQUENCEREFERENCE DATATYPESAMEASID int 4 7
53 bmssa NUMBERSEQUENCEREFERENCE DEL_WIZARDRESTRICTIONLEVEL int 4 8
54 bmssa NUMBERSEQUENCEREFERENCE WIZARDCONTINUOUS int 4 9
55 bmssa NUMBERSEQUENCEREFERENCE ALLOWSAMEAS int 4 10
56 bmssa NUMBERSEQUENCEREFERENCE SORTFIELD int 4 11
57 bmssa NUMBERSEQUENCEREFERENCE GROUPENABLED int 4 12
58 bmssa NUMBERSEQUENCEREFERENCE WIZARDHIGHEST int 4 13
59 bmssa NUMBERSEQUENCEREFERENCE WIZARDLOWEST int 4 14
60 bmssa NUMBERSEQUENCEREFERENCE WIZARDALLOWCHANGEDOWN int 4 15
61 bmssa NUMBERSEQUENCEREFERENCE WIZARDALLOWCHANGEUP int 4 16
62 bmssa NUMBERSEQUENCEREFERENCE WIZARDMANUAL int 4 17
63 bmssa NUMBERSEQUENCEREFERENCE WIZARDFETCHAHEADQTY int 4 18
64 bmssa NUMBERSEQUENCEREFERENCE DEL_LEDGERPOSTINGJOURNALID varchar 10 19
65 bmssa NUMBERSEQUENCEREFERENCE DATAAREAID varchar 3 20
66 bmssa NUMBERSEQUENCEREFERENCE RECVERSION int 4 21
67 bmssa NUMBERSEQUENCEREFERENCE RECID int 4 22
68 bmssa NUMBERSEQUENCETABLE NUMBERSEQUENCE varchar 10 1
69 bmssa NUMBERSEQUENCETABLE TXT varchar 35 2
70 bmssa NUMBERSEQUENCETABLE LOWEST int 4 3
71 bmssa NUMBERSEQUENCETABLE HIGHEST int 4 4
72 bmssa NUMBERSEQUENCETABLE NEXTREC int 4 5
73 bmssa NUMBERSEQUENCETABLE BLOCKED int 4 6
74 bmssa NUMBERSEQUENCETABLE FORMAT varchar 20 7
75 bmssa NUMBERSEQUENCETABLE CONTINUOUS int 4 8
76 bmssa NUMBERSEQUENCETABLE CYCLIC int 4 9
77 bmssa NUMBERSEQUENCETABLE CLEANATACCESS int 4 10
78 bmssa NUMBERSEQUENCETABLE INUSE int 4 11
79 bmssa NUMBERSEQUENCETABLE NOINCREMENT int 4 12
80 bmssa NUMBERSEQUENCETABLE DEL_RESTRICTIONLEVEL int 4 13
81 bmssa NUMBERSEQUENCETABLE CLEANINTERVAL numeric 13 14
82 bmssa NUMBERSEQUENCETABLE LATESTCLEANDATE datetime 8 15
83 bmssa NUMBERSEQUENCETABLE LATESTCLEANTIME int 4 16
84 bmssa NUMBERSEQUENCETABLE ALLOWCHANGEUP int 4 17
85 bmssa NUMBERSEQUENCETABLE ALLOWCHANGEDOWN int 4 18
86 bmssa NUMBERSEQUENCETABLE MANUAL int 4 19
87 bmssa NUMBERSEQUENCETABLE FETCHAHEADQTY int 4 20
88 bmssa NUMBERSEQUENCETABLE FETCHAHEAD int 4 21
89 bmssa NUMBERSEQUENCETABLE DEL_FORCEDATEORDER int 4 22
90 bmssa NUMBERSEQUENCETABLE MODIFIEDTRANSACTIONID int 4 23
91 bmssa NUMBERSEQUENCETABLE DATAAREAID varchar 3 24
92 bmssa NUMBERSEQUENCETABLE RECVERSION int 4 25
93 bmssa NUMBERSEQUENCETABLE RECID int 4 26
94 bmssa SQLDICTIONARY TABLEID int 4 1
95 bmssa SQLDICTIONARY FIELDID int 4 2
96 bmssa SQLDICTIONARY ARRAY int 4 3
97 bmssa SQLDICTIONARY NAME varchar 40 4
98 bmssa SQLDICTIONARY SQLNAME varchar 40 5
99 bmssa SQLDICTIONARY FIELDTYPE int 4 6
100 bmssa SQLDICTIONARY STRSIZE int 4 7
101 bmssa SQLDICTIONARY SHADOW int 4 8
102 bmssa SQLDICTIONARY RIGHTJUSTIFY int 4 9
103 bmssa SQLDICTIONARY NULLABLE int 4 10
104 bmssa SQLDICTIONARY FLAGS int 4 11
105 bmssa SQLDICTIONARY RECVERSION int 4 12
106 bmssa SQLDICTIONARY RECID int 4 13
107 bmssa SYSCONFIG CONFIGTYPE int 4 1
108 bmssa SYSCONFIG ID int 4 2
109 bmssa SYSCONFIG VALUE varchar 80 3
110 bmssa SYSCONFIG MODIFIEDDATE datetime 8 4
111 bmssa SYSCONFIG MODIFIEDTIME int 4 5
112 bmssa SYSCONFIG MODIFIEDBY varchar 5 6
113 bmssa SYSCONFIG CREATEDDATE datetime 8 7
114 bmssa SYSCONFIG CREATEDTIME int 4 8
115 bmssa SYSCONFIG CREATEDBY varchar 5 9
116 bmssa SYSCONFIG RECVERSION int 4 10
117 bmssa SYSCONFIG RECID int 4 11
118 bmssa SYSTEMSEQUENCES ID int 4 1
119 bmssa SYSTEMSEQUENCES NEXTVAL int 4 2
120 bmssa SYSTEMSEQUENCES MINVAL int 4 3
121 bmssa SYSTEMSEQUENCES MAXVAL int 4 4
122 bmssa SYSTEMSEQUENCES CYCLE int 4 5
123 bmssa SYSTEMSEQUENCES NAME varchar 20 6
124 bmssa SYSTEMSEQUENCES TABID int 4 7
125 bmssa SYSTEMSEQUENCES DATAAREAID varchar 3 8
126 bmssa SYSTEMSEQUENCES RECVERSION int 4 9
127 bmssa SYSTEMSEQUENCES RECID int 4 10

View file

@ -113,7 +113,3 @@ dotnet SQLInsight.Hub.dll delete-customer "Kunde A/S" --confirm
```
Alle kundens instanser, fund, regelstatus, modtagelser og supportsager slettes. Licenserne spærres, så agenterne ikke kan sende igen, og kunden omdøbes til "Slettet kunde #id". Se også [databehandling.md](databehandling.md).
### Kunden lukker en sag
`POST /api/v1/cases/{reference}/close` (licens som Authorization, kræver support i licensen) lukker en af licensens egne sager; andres sager giver 404. Body `{ comment?, requestedBy? }`. Sagen får `ClosedBy = "Kunde: <bruger>"` og kommentaren; lukning i portalen giver `ClosedBy = "SEOSoft: <medarbejder>"`. Kaldet er idempotent. Agenten sender lukninger, som kunden har bedt om i SQLInsight Web, og en senere liste fra hubben sætter ikke en ventende lukning tilbage til åben.

View file

@ -179,7 +179,3 @@ Begge programmer understøtter systemd (`Type=notify`). Windows-login på Linux
## Opdatering
Se [opdatering.md](opdatering.md): regelpakken opdateres automatisk, og nye versioner kan installeres med `SQLInsight.Agent update apply` eller automatisk af en planlagt opgave. Manuelt: stop tjenesten (eller app poolen), kopiér de nye filer ind uden at overskrive `appsettings.json`, og start igen. Agenten opgraderer repository-skemaet selv.
## Menuen i webbrugerfladen
Menuen er delt i grupper, der kan foldes ud og sammen: **Overvågning**, **Analyse**, **Konfiguration og sikkerhed**, **Rapporter**, **Dynamics AX** (kun når en database er genkendt som AX), **Support**, **System** og **Community**. Gruppen med den side, man står på, er åben. Sider, der findes pr. instans (fx Analyse af tidsrum og Konfiguration), åbner et valg af instans; er der kun én instans, går de direkte til den. På en smal skærm (mobil) folder hele menuen sig sammen bag knappen **Menu**. Alt virker uden JavaScript.

View file

@ -60,63 +60,17 @@ Eksempel på alarm i Prometheus:
## JSON-API
Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de samme tidsrum og filtre, og rapporterne også som PDF. API'et er beskrevet i OpenAPI på `GET /api/v1/openapi.json` (samme login som API'et), som fx Postman, Power BI eller en kodegenerator kan læse. Tider er ISO 8601 (UTC eller med tidszone), fx `2026-10-01T08:00:00Z`.
| Kald | Svar |
|---|---|
| `GET /api/v1/instances` | Instanser med version, seneste kontakt, `up` og antal fund pr. alvor |
| `GET /api/v1/findings` | Fund. Filtre: `status=active\|resolved\|all` (standard active), `from`/`to` (opstået for aktive, løst for løste, begge for all), `instance`, `database` (emnet nævner databasen), `ruleId`, `minSeverity=Info\|Low\|Medium\|High\|Critical`, `category` (fx `SEC` i SQLI-SEC-001), `sort=severity\|firstSeen\|lastSeen\|resolved`, `limit` (1–1000, standard 100). Er der flere, står værdien til `?continuation=` i svar-headeren `X-Continuation` |
| `GET /api/v1/findings/{id}` | Ét fund med forklaring, anbefaling, målinger, fix-script, referencer og historik (først/sidst set, løst) |
| `GET /api/v1/rules` | Alle regler med id, titel, kategori og kilde (`indbygget`, `community`, `lokal`) |
| `GET /api/v1/instances/{navn}/rule-states` | Hver regels udfald på instansen: `passed`, `findings`, `notMeasurable`, `notApplicable`, `error`, med årsag |
| `GET /api/v1/reports` | Rapporttyperne og deres parametre |
| `GET /api/v1/reports/driftsrapport?days=30&format=json\|pdf` | Driftsrapporten: nøgletal, sammenfatning, kontroller pr. instans, instanser, aktive fund, konfigurationsændringer |
| `GET /api/v1/reports/analyse?instance=…&from=…&to=…&sort=cpu&format=json\|pdf` | Analyse af et tidsrum (som siden og PDF'en) |
| `GET /api/v1/reports/rettigheder?days=90&instance=…&format=json\|pdf` | Rettighedsrevisionen for én eller alle instanser |
| `GET /api/v1/instances/{navn}/analysis?from=&to=&sort=` | Analyse af tidsrum: målinger med vurdering, ventetider, tungeste sætninger med aktivitet, diske, blocking, deadlocks, langvarige operationer, fund |
| `GET /api/v1/instances/{navn}/top-sql?from=&to=&sort=cpu\|varighed\|laesninger\|koersler&limit=` | Tungeste sætninger i et tidsrum (standard seneste døgn) |
| `GET /api/v1/instances/{navn}/disks?from=&to=` | Diskhastighed pr. drev med vurdering og tidsserier |
| `GET /api/v1/instances/{navn}/configuration?days=90` | Opsætning, tjenester, tempdb, sp_configure, trace flags og ændringslog |
| `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer |
| `GET /api/v1/privileges?days=90` | Det samme for alle instanser |
| `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede |
| `POST /api/v1/support-cases`, `GET /api/v1/support-cases[/{id}]`, `POST /api/v1/support-cases/{id}/close` | Supportsager: se [support.md](support.md) (kræver scope `support:write` for at skrive) |
| `GET /api/v1/findings?instance=prod-sql01&minSeverity=High` | Aktive fund med forklaring, anbefaling og målinger |
| `GET /metrics` | Prometheus-format |
**Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data. PDF-rapporterne indeholder de samme data som i webbrugerfladen.
**Tokens og scopes:** et token i den gamle form (`"navn": "sha256:…"`) må kun læse (`read`). Et token med flere rettigheder skrives som
```json
"ApiTokens": { "itsm": { "hash": "sha256:…", "scopes": [ "read", "support:write" ] } }
```
og oprettes med `SQLInsight.Web.exe new-api-token itsm --scope read,support:write`. Windows-brugere med adgang til webbrugerfladen må det samme som i webbrugerfladen.
PowerShell:
```powershell
$h = @{ Authorization = "Bearer $token" }
Invoke-RestMethod "https://sqlinsight.domæne.local/api/v1/findings?minSeverity=High" -Headers $h
Invoke-WebRequest "https://sqlinsight.domæne.local/api/v1/reports/driftsrapport?days=30&format=pdf" -Headers $h -OutFile driftsrapport.pdf
# Alle løste fund det seneste kvartal, side for side
$url = "https://sqlinsight.domæne.local/api/v1/findings?status=resolved&from=$((Get-Date).AddDays(-90).ToString('o'))&limit=500"
$all = @(); $next = $null
do {
$r = Invoke-WebRequest ($url + $(if ($next) { "&continuation=$next" })) -Headers $h
$all += $r.Content | ConvertFrom-Json
$next = $r.Headers['X-Continuation']
} while ($next)
Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/findings?minSeverity=High -Headers @{ Authorization = "Bearer $token" }
```
curl:
```bash
curl -H "Authorization: Bearer $TOKEN" "https://sqlinsight.domæne.local/api/v1/instances/AX01%5CAX30/top-sql?sort=cpu&limit=10"
```
**Power BI og Excel:** Hent data → Fra internettet → Avanceret, angiv URL'en (fx `/api/v1/findings?status=all&limit=1000`) og en HTTP-header `Authorization` med værdien `Bearer <token>`. Brug et token med kun `read`.
Følsomme målinger (forespørgselstekst, logins, værtsnavne) vises som `[udeladt]` i API'et. Sæt `SQLInsight:Web:ApiIncludesSensitiveEvidence` til `true` for at få dem med.
## SCOM og Windows' hændelseslog

View file

@ -1,21 +0,0 @@
# Konfiguration og ændringslog
Hver instans har en side **Konfiguration** (knappen øverst på instansens side) med, hvordan instansen er sat op:
| Afsnit | Hvad |
|---|---|
| Instans | Version, edition, collation og standardmapper til data og log (SQL Server 2012+) |
| CPU og hukommelse | Logiske CPU'er, schedulers, NUMA-noder, fysisk hukommelse, virtuel maskine, max/min server memory, Lock Pages in Memory, Instant File Initialization, MAXDOP og cost threshold for parallelism |
| Tjenester | SQL Server- og SQL Agent-tjenesterne med konto, start og status (`sys.dm_server_services`, SQL Server 2008 R2 SP1+) |
| Tempdb | Filerne med størrelse, autogrowth og maksimum |
| Trace flags | Globalt slåede trace flags |
| Indstillinger, der afviger fra standard | sp_configure-værdier, der ikke står på SQL Servers standard, og værdier, der venter på `RECONFIGURE` eller genstart |
| Ændringslog | Alt, hvad der er ændret (7, 30, 90 dage eller 1 år) |
Lock Pages in Memory og Instant File Initialization kan kun aflæses fra SQL Server 2012 SP4 og 2016 SP1. På ældre versioner står der "kan ikke aflæses".
## Ændringer
Agenten læser konfigurationen hvert 15. minut (collector `instance-config`) og sammenligner med sidste gang, også på tværs af en genstart af agenten. Ændringer af sp_configure, trace flags og tjenestekonti giver fundet **SQLI-CFG-013 Konfiguration ændret** (information, i et døgn), og alle ændringer, også version og antal tempdb-filer, står i ændringsloggen og i driftsrapporten. Den første måling efter installationen er udgangspunktet.
Tidspunktet er, hvornår ændringen blev opdaget, ikke hvornår den blev lavet. Ændringsloggen gemmes lige så længe som løste fund (`SQLInsight:Retention:ResolvedFindingDays`, standard 365 dage).

View file

@ -9,7 +9,6 @@ SQLInsight Pro laver en **driftsrapport** som PDF til ledelse, it-chef og revisi
- **Kontroller** til compliance: supporteret version, opdateret, fuld backup, log-backup, integritetstjek, sidekontrol, tilgængelighed, hærdning (xp_cmdshell, TRUSTWORTHY) og vedligeholdelsesjobs. For hver kontrol vises, hvor mange instanser der opfylder den, ikke opfylder den, eller ikke kunne måles, og hvilke instanser der ikke opfylder den.
- **Instanser** med version, CU-niveau og antal fund.
- **Kritiske fund og fund med høj alvor** med alder.
- **Konfigurationsændringer i perioden**: sp_configure, trace flags, tjenestekonti, version og antal tempdb-filer, med fra/til og hvornår ændringen blev opdaget (se [konfiguration.md](konfiguration.md)).
"Ikke målt" betyder, at SQL Server-versionen, rettighederne eller en fejl forhindrede målingen. Det tælles aldrig som opfyldt.
@ -23,17 +22,6 @@ Navnet på rapporten sættes i webbens `appsettings.json`:
"SQLInsight": { "Web": { "Organization": "Nordisk Logistik A/S", "TimeZone": "Europe/Copenhagen" } }
```
## Rettighedsrevision
En særskilt PDF til revisionen om forhøjede serverrettigheder, for én instans (knappen under **Rettigheder** på instansens side) eller alle (under **Rapporter**), for en valgt periode. Den indeholder:
- de nuværende rettigheder (faste serverroller og CONTROL SERVER) med Windows-grupper foldet ud til personer, og antallet af personer med sysadmin mod grænsen;
- ændringer i perioden med hvem og hvornår (fra default trace);
- sa-kontoens status, SQL-logins uden adgangskodepolitik og default trace;
- en redegørelse for datakilde og metode.
Adressen er `/rapporter/rettigheder.pdf?dage=90` (alle) eller `…&instans=<navn>`. Rapporten indeholder navne: den dannes i webbrugerfladen for den, der er logget ind, og sendes aldrig til SQLInsight Hub. Se [rettigheder.md](rettigheder.md).
## Månedlig rapport på mail
Agenten kan sende sidste måneds rapport automatisk. Den bruger SMTP-indstillingerne fra notifikationerne (`SQLInsight:Notifications:Email`: Host, Port, Security, From og eventuelt login). E-mail-notifikationerne behøver ikke være slået til.
@ -49,9 +37,3 @@ Agenten kan sende sidste måneds rapport automatisk. Den bruger SMTP-indstilling
```
Rapporten dækker den foregående kalendermåned og sendes på den angivne dag og time. Var agenten stoppet på det tidspunkt, sendes den, så snart den kører igen i samme måned. Den sendes kun én gang pr. måned.
## Ikke relevant, ikke målt og tjek manuelt
- **Ikke relevant**: funktionen findes ikke i SQL Server-versionen (fx Query Store før 2016, availability groups før 2012) eller håndteres af Azure. Tælles ikke med.
- **Ikke målt**: rettigheder eller en fejl forhindrede målingen. Det er et hul i overvågningen og tælles som "ikke målbar".
- **Tjek manuelt**: emnet er relevant, men målemetoden kræver en nyere version eller patch (fx status for DBCC CHECKDB før SQL Server 2016 SP2, VLF-antal, spill og memory grants før 2016 SP2/2017 CU3, deadlocks på 2005). Reglen beskriver, hvordan det tjekkes i hånden. Det vises i sektionen **Skal tjekkes manuelt** på instansens side, i nøgletallet "skal tjekkes manuelt" på overblikket, i kontrollerne i driftsrapporten og som `manualCheck` i API'et – og tæller ikke som "ikke målbar".

View file

@ -74,11 +74,3 @@ Når reglen virker, kopieres filen til `rules`-mappen i agentens datamappe. Den
4. Opret en pull request på [git.seosoft.dk/seo/sqlinsight-pro](https://git.seosoft.dk/seo/sqlinsight-pro). Testen `Community_rules_in_the_repository_are_valid_and_ship_with_the_build` fanger format- og sikkerhedsfejl.
Efter review kommer reglen med i næste version og i næste regelpakke, så eksisterende installationer får den uden opdatering af programmet.
## Community-regler i pakken
| Fil | Regler |
|---|---|
| `dynamics-ax.json` | COM-AX-*: Dynamics AX-specifikke tjek (version, MAXDOP, …) |
| `integrity.json`, `security.json` | Integritet og sikkerhed |
| `fulltext.json` | COM-FT-001 katalog stoppet pga. fuld disk (Høj), COM-FT-002 katalog på pause eller droslet (Middel), COM-FT-003/004 fulltext-indeks med manuel eller slået-fra change tracking, ikke opdateret i 7/30 dage (Lav/Middel), COM-FT-005 deaktiveret fulltext-indeks (Middel). Kun metadata (`sys.fulltext_catalogs`, `sys.fulltext_indexes`, `FULLTEXTCATALOGPROPERTY`); SQL Server 2005-syntaks. |

View file

@ -1,64 +0,0 @@
# Rettigheder og rettighedsrevision
SQLInsight Pro holder øje med, hvem der har **sysadmin** og de øvrige forhøjede serverrettigheder, og hvem der får eller mister dem. Det er typisk det første, en revisor spørger til.
## Hvad måles
Hvert 15. minut læser agenten:
- medlemmer af de faste serverroller `sysadmin`, `securityadmin`, `serveradmin`, `setupadmin`, `processadmin`, `diskadmin`, `dbcreator` og `bulkadmin`;
- logins med `CONTROL SERVER`;
- for hvert login: type (SQL-login, Windows-login, Windows-gruppe …) og om det er deaktiveret;
- for SQL-logins: om de følger Windows' adgangskodepolitik (`CHECK_POLICY`) og udløb (`CHECK_EXPIRATION`), og om `sa` er aktiv;
- om SQL Servers **default trace** er slået til.
Det kræver kun den rettighed, agenten i forvejen har (`VIEW ANY DEFINITION`). Det virker fra SQL Server 2005.
### Windows-grupper foldes ud
Har en Windows-gruppe (fx `KUNDE\SQL-Admins`) sysadmin, er det personerne i gruppen, der har den. Agenten spørger derfor Active Directory om gruppens medlemmer, også medlemmer af grupper i gruppen. Svaret gemmes en time (fejl i 10 minutter), og et opslag afbrydes efter 20 sekunder, så en langsom domænecontroller ikke forsinker overvågningen.
Det kræver, at agenten kører på **Windows i domænet**. Standardkontoen (`NT SERVICE\SQLInsight Agent`) taler med AD som maskinens computerkonto, som normalt må læse gruppemedlemskaber. Kan en gruppe ikke foldes ud, står der **"kunne ikke foldes ud"** og årsagen ved gruppen (fx en lokal gruppe på SQL-serveren, en gruppe i et domæne uden tillid, eller timeout). Gruppens medlemmer tælles så ikke med, og antallet vises som "3+" i oversigten.
Slå udfoldningen fra med:
```json
"SQLInsight": { "Privileges": { "ExpandWindowsGroups": false } }
```
En agent på Linux folder ikke grupper ud.
## Ændringer, og hvem der lavede dem
Hver måling sammenlignes med den forrige. **Den første måling efter installation er udgangspunktet**: eksisterende medlemmer meldes ikke som nye.
- Direkte medlemmer (logins) sammenlignes på navn og rettighed.
- Personer i Windows-grupper sammenlignes kun, når gruppen kunne foldes ud begge gange. En gruppe, AD ikke svarede for, ligner altså ikke, at alle har forladt den.
**Hvem og hvornår** læses fra default trace (hændelse 108, "Audit Add Login to Server Role"). Det kræver, at agenten har rettigheden `ALTER TRACE`, som er et tilvalg i `db/permissions/grant-least-privilege.sql` (se kommentaren i scriptet). Uden den findes ændringen stadig, men "Ændret af" står som "ukendt" med årsagen. Bemærk:
- SQL Server skriver default trace i buffere; en ændring lige før en måling får derfor ofte sin forfatter først ved næste måling, og ændringsloggen opdateres så.
- Default trace gemmer kun de seneste hændelser (5 filer à 20 MB). På en travl server kan hændelsen være væk, hvis agenten har været stoppet længe.
- Ændringer i AD-gruppers medlemmer står ikke i SQL Servers trace. De vises med "Active Directory" som kilde; hvem der ændrede gruppen, skal findes i domænecontrollerens sikkerhedslog.
- `CONTROL SERVER` (GRANT) har ikke hændelse 108 og vises uden forfatter.
## Fund
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-SEC-001 Ny forhøjet rettighed | **Høj** for sysadmin, securityadmin og CONTROL SERVER; Middel for de øvrige roller | En konto eller person har fået rettigheden. Fundet er åbent i 7 dage og vises øverst på instanssiden og overblikket og sendes som notifikation. |
| SQLI-SEC-002 For mange med sysadmin | Middel | Flere personer og konti med sysadmin end grænsen (standard 3). Tjenestekonti (`NT SERVICE\…`, `NT AUTHORITY\…`, `##…##`, konti der ender på `$`) og deaktiverede logins tælles ikke. |
Grænsen sættes i agentens konfiguration:
```json
"SQLInsight": { "Privileges": { "MaxSysadminAccounts": 3 } }
```
**Navne er følsomme.** Hvem der fik rettigheden, hvilken gruppe og hvem der ændrede den, er følsomme målinger: de vises lokalt, men sendes som `[udeladt]` til SQLInsight Hub og i integrations-API'et (medmindre kunden selv slår det til). Fundets titel og emne indeholder aldrig navne.
## I webbrugerfladen
- **Instans → Rettigheder** (`/instanser/{navn}/rettigheder`): personer med sysadmin efter udfoldning, alle forhøjede rettigheder med gruppernes medlemmer, sa, SQL-logins uden adgangskodepolitik, default trace og ændringsloggen for 7, 30, 90 dage eller 1 år.
- **Rettigheder** i menuen (`/rettigheder`): alle instanser i én tabel og en samlet ændringslog.
- **Revisionsrapport (PDF)**: se [rapporter.md](rapporter.md).

View file

@ -23,24 +23,3 @@ Siden **Support** viser alle jeres sager, også dem SEOSoft har oprettet for jer
Webbrugerfladens login har kun `db_datareader` på repositoriet. For at kunne oprette en sag har rollen `public` i repository-databasen fået `INSERT` på den ene tabel `insight.SupportCases` (migration `0005_support_cases.sql`). Kun repositoriets egne brugere findes i den database.
Hvad der sendes til SEOSoft, står i [databehandling.md](databehandling.md).
## Supportsager via API'et
Sager kan oprettes, læses og lukkes fra fx et ITSM-system (ServiceNow, TOPdesk) eller PowerShell. Det kræver det samme som formularen (Professional/Enterprise og deling med SQLInsight Hub), og et token med scope `support:write` (oprettes med `SQLInsight.Web.exe new-api-token itsm --scope read,support:write`). Windows-brugere med adgang til webbrugerfladen må det samme.
| Kald | Svar |
|---|---|
| `POST /api/v1/support-cases` | Body `{ title, description, contactName, contactEmail, contactPhone?, findingId? }`. Med `findingId` udfyldes titel og beskrivelse med fundets ikke-følsomme oplysninger. Svar 202 med lokalt `id` og status `Pending`; sagsnummeret (SUP-…) kommer, når agenten har talt med hubben. Header `Idempotency-Key` giver samme sag ved gentagelse. 403 (problem+json) uden scope eller licens |
| `GET /api/v1/support-cases?status=&from=&to=&limit=` | Sagerne, nyeste først (sider med `X-Continuation`) |
| `GET /api/v1/support-cases/{id}` | Status (`Pending`, `Open`, `CloseRequested`, `Closed`, `Failed`), sagsnummer, oprettet/lukket, lukket af, fejl |
| `POST /api/v1/support-cases/{id}/close` | Body `{ comment? }`. Status `CloseRequested`, indtil hubben har bekræftet, så `Closed`. Allerede lukket: 200. En sag uden sagsnummer endnu lukkes, så snart den er oprettet |
Sager kan også lukkes med knappen **Luk sag** på siden Support. SEOSoft ser i portalen, hvem der lukkede sagen (kunde eller SEOSoft) og kommentaren, og supportrapporten har kolonnen "Lukket af".
```powershell
$h = @{ Authorization = "Bearer $token"; "Idempotency-Key" = "INC0012345" }
$sag = Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/support-cases -Method Post -Headers $h -ContentType application/json `
-Body (@{ contactName = "Servicedesk"; contactEmail = "servicedesk@firma.dk"; findingId = 4711 } | ConvertTo-Json)
# senere: sagsnummer og status
Invoke-RestMethod "https://sqlinsight.domæne.local/api/v1/support-cases/$($sag.id)" -Headers $h
```

View file

@ -1,75 +0,0 @@
// Full-text search: catalogs whose population has stopped, indexes that are out of date or disabled.
//
// Only metadata (sys.fulltext_catalogs, sys.fulltext_indexes, FULLTEXTCATALOGPROPERTY, OBJECTPROPERTY): no data is read,
// and nothing is changed. Everything is SQL Server 2005 syntax. In databases without full-text the rules find nothing.
//
// FULLTEXTCATALOGPROPERTY(…, 'PopulateStatus'): 0 idle, 1 full population, 2 paused, 3 throttled, 4 recovering,
// 5 shutdown, 6 incremental population, 7 building index, 8 disk full (paused), 9 change tracking.
// 'PopulateCompletionAge' is seconds from 1990-01-01 to the last completed population.
[
{
"id": "COM-FT-001",
"title": "Fulltext-katalog er sat på pause, fordi disken er fuld",
"severity": "High",
"scope": "Database",
"query": "SELECT c.name AS [catalog], FULLTEXTCATALOGPROPERTY(c.name, 'ItemCount') AS [items] FROM sys.fulltext_catalogs c WHERE FULLTEXTCATALOGPROPERTY(c.name, 'PopulateStatus') = 8",
"subject": "fulltext: {database}/{catalog}",
"findingTitle": "Fulltext-kataloget {catalog} i {database} er stoppet: disken er fuld",
"explanation": "SQL Server har sat opbygningen af fulltext-kataloget {catalog} på pause, fordi der ikke er plads på disken. Søgninger med CONTAINS og FREETEXT bruger det indeks, der var bygget, så nye og ændrede rækker findes ikke. Det fortsætter ikke af sig selv, før der er plads.",
"recommendation": "Frigør plads på drevet med databasens datafiler (eller fulltext-katalogets mappe på SQL Server 2005), og genoptag derefter: ALTER FULLTEXT CATALOG [{catalog}] REBUILD, eller start en fuld population af tabellernes fulltext-indexes (ALTER FULLTEXT INDEX ON … START FULL POPULATION).",
"evidence": [ "catalog", "items" ],
"author": "SQLInsight Pro"
},
{
"id": "COM-FT-002",
"title": "Fulltext-katalog er sat på pause eller drosles",
"severity": "Medium",
"scope": "Database",
"query": "SELECT c.name AS [catalog], CASE FULLTEXTCATALOGPROPERTY(c.name, 'PopulateStatus') WHEN 2 THEN 'sat på pause' ELSE 'droslet' END AS [status] FROM sys.fulltext_catalogs c WHERE FULLTEXTCATALOGPROPERTY(c.name, 'PopulateStatus') IN (2, 3)",
"subject": "fulltext: {database}/{catalog}",
"findingTitle": "Fulltext-kataloget {catalog} i {database} er {status}",
"explanation": "Opbygningen af fulltext-kataloget {catalog} er {status}. Mens den står stille, finder fulltext-søgninger ikke nye og ændrede rækker. 'Droslet' betyder, at SQL Server har bremset opbygningen på grund af ressourcemangel (hukommelse eller CPU); 'pause' kan være sat manuelt eller af SQL Server.",
"recommendation": "Tjek status i SQL Server Management Studio under Storage > Full Text Catalogs og fulltext-loggen (SQLFT*.log i SQL Servers logmappe). Genoptag med ALTER FULLTEXT INDEX ON <tabel> RESUME POPULATION. Bliver den ved med at drosles, så se på hukommelse og CPU på serveren.",
"evidence": [ "catalog", "status" ],
"author": "SQLInsight Pro"
},
{
"id": "COM-FT-003",
"title": "Fulltext-indeks uden automatisk opdatering er ikke opdateret i over en uge",
"severity": "Low",
"scope": "Database",
"query": "SELECT OBJECT_NAME(i.object_id) AS [table], c.name AS [catalog], CASE i.change_tracking_state WHEN 'M' THEN 'manuel' ELSE 'slået fra' END AS [change_tracking], CONVERT(varchar(16), DATEADD(second, FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge'), '19900101'), 120) AS [last_population] FROM sys.fulltext_indexes i JOIN sys.fulltext_catalogs c ON c.fulltext_catalog_id = i.fulltext_catalog_id WHERE i.change_tracking_state IN ('M', 'O') AND i.is_enabled = 1 AND FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge') > 0 AND DATEADD(second, FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge'), '19900101') < DATEADD(day, -7, GETDATE()) AND DATEADD(second, FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge'), '19900101') >= DATEADD(day, -30, GETDATE())",
"subject": "fulltext: {database}/{table}",
"findingTitle": "Fulltext-indekset på {table} i {database} er sidst opdateret {last_population}",
"explanation": "Fulltext-indekset på {table} opdateres ikke automatisk (change tracking er {change_tracking}), og kataloget {catalog} blev sidst bygget {last_population}. Søgninger finder ikke rækker, der er oprettet eller ændret siden.",
"recommendation": "Opdatér det regelmæssigt fra et job (ALTER FULLTEXT INDEX ON [{table}] START INCREMENTAL POPULATION, kræver en timestamp-kolonne, ellers START FULL POPULATION), eller slå automatisk opdatering til: ALTER FULLTEXT INDEX ON [{table}] SET CHANGE_TRACKING AUTO.",
"evidence": [ "table", "catalog", "change_tracking", "last_population" ],
"author": "SQLInsight Pro"
},
{
"id": "COM-FT-004",
"title": "Fulltext-indeks uden automatisk opdatering er ikke opdateret i over 30 dage",
"severity": "Medium",
"scope": "Database",
"query": "SELECT OBJECT_NAME(i.object_id) AS [table], c.name AS [catalog], CASE i.change_tracking_state WHEN 'M' THEN 'manuel' ELSE 'slået fra' END AS [change_tracking], CONVERT(varchar(16), DATEADD(second, FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge'), '19900101'), 120) AS [last_population] FROM sys.fulltext_indexes i JOIN sys.fulltext_catalogs c ON c.fulltext_catalog_id = i.fulltext_catalog_id WHERE i.change_tracking_state IN ('M', 'O') AND i.is_enabled = 1 AND FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge') > 0 AND DATEADD(second, FULLTEXTCATALOGPROPERTY(c.name, 'PopulateCompletionAge'), '19900101') < DATEADD(day, -30, GETDATE())",
"subject": "fulltext: {database}/{table}",
"findingTitle": "Fulltext-indekset på {table} i {database} er ikke opdateret siden {last_population}",
"explanation": "Fulltext-indekset på {table} opdateres ikke automatisk (change tracking er {change_tracking}), og kataloget {catalog} er ikke bygget i over 30 dage (sidst {last_population}). Søgninger med CONTAINS og FREETEXT giver derfor forældede resultater.",
"recommendation": "Opdatér det regelmæssigt fra et job (ALTER FULLTEXT INDEX ON [{table}] START INCREMENTAL POPULATION, kræver en timestamp-kolonne, ellers START FULL POPULATION), eller slå automatisk opdatering til: ALTER FULLTEXT INDEX ON [{table}] SET CHANGE_TRACKING AUTO. Bruges indekset ikke, så overvej at fjerne det.",
"evidence": [ "table", "catalog", "change_tracking", "last_population" ],
"author": "SQLInsight Pro"
},
{
"id": "COM-FT-005",
"title": "Fulltext-indeks er deaktiveret",
"severity": "Medium",
"scope": "Database",
"query": "SELECT OBJECT_NAME(i.object_id) AS [table], c.name AS [catalog] FROM sys.fulltext_indexes i JOIN sys.fulltext_catalogs c ON c.fulltext_catalog_id = i.fulltext_catalog_id WHERE i.is_enabled = 0",
"subject": "fulltext: {database}/{table}",
"findingTitle": "Fulltext-indekset på {table} i {database} er deaktiveret",
"explanation": "Fulltext-indekset på {table} (kataloget {catalog}) er slået fra. Det opdateres ikke, og fulltext-søgninger mod tabellen fejler eller finder intet, afhængigt af hvordan applikationen søger.",
"recommendation": "Var det ikke med vilje (fx under en dataindlæsning), så slå det til igen: ALTER FULLTEXT INDEX ON [{table}] ENABLE, og start en population. Bruges det ikke, så fjern det: DROP FULLTEXT INDEX ON [{table}].",
"evidence": [ "table", "catalog" ],
"author": "SQLInsight Pro"
}
]

View file

@ -20,10 +20,6 @@ public sealed class AgentOptions
public RetentionOptions Retention { get; set; } = new();
public PrivilegeOptions Privileges { get; set; } = new();
public TopSqlOptions TopSql { get; set; } = new();
public IList<MonitoredInstance> Instances { get; } = [];
}
@ -66,23 +62,3 @@ public sealed class MonitoredInstance
TenantId = TenantId,
};
}
/// <summary>The privilege audit (sysadmin and the other elevated server rights).</summary>
public sealed class PrivilegeOptions
{
/// <summary>More people and accounts with sysadmin than this is a finding (SQLI-SEC-002).</summary>
public int MaxSysadminAccounts { get; set; } = 3;
/// <summary>Unfold Windows groups with server rights to the people in them, by asking Active Directory (Windows only).</summary>
public bool ExpandWindowsGroups { get; set; } = true;
}
/// <summary>Top SQL (the heaviest statements).</summary>
public sealed class TopSqlOptions
{
/// <summary>
/// A statement whose CPU time is more than this share of the time it has been in the plan cache runs almost
/// constantly (SQLI-PERF-…); 100 means one CPU core all the time.
/// </summary>
public double ActivityThresholdPercent { get; set; } = 50;
}

View file

@ -163,40 +163,6 @@ public sealed partial class HubSyncService(
}
}
// Closings requested in SQLInsight Web, for cases the hub has given a number.
foreach (var item in await repository.GetDueSupportClosesAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))
{
var now = time.GetUtcNow();
try
{
var body = new Contracts.SupportCaseCloseRequest(item.CloseComment, item.CloseRequestedBy);
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(settings.Url!, $"api/v1/cases/{Uri.EscapeDataString(item.Reference!)}/close"))
{
Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body, HubOutbox.Json), Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, settings.License);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode && System.Text.Json.JsonSerializer.Deserialize<Contracts.SupportCaseDto>(text, HubOutbox.Json) is { } dto)
{
await repository.MarkSupportClosedAsync(item.CaseId, dto.ClosedAt?.UtcDateTime, dto.ClosedBy, now, cancellationToken).ConfigureAwait(false);
created++;
continue;
}
var error = $"{(int)response.StatusCode} {response.ReasonPhrase}: {Truncate(text)}";
var permanent = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
or HttpStatusCode.NotFound or HttpStatusCode.UnprocessableEntity;
await repository.MarkSupportCloseFailedAsync(item.CaseId, error, now, permanent ? null : now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false);
LogCaseRejected(logger, item.Title, error);
}
catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
await repository.MarkSupportCloseFailedAsync(item.CaseId, ex.Message, now, now + Backoff(item.CloseAttempts), cancellationToken).ConfigureAwait(false);
LogCaseUnreachable(logger, ex.Message);
}
}
if (created == 0 && _casesFetchedAt is { } fetched && time.GetUtcNow() - fetched < CasesInterval)
{
return;
@ -215,7 +181,7 @@ public sealed partial class HubSyncService(
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var cases = System.Text.Json.JsonSerializer.Deserialize<List<Contracts.SupportCaseDto>>(json, HubOutbox.Json) ?? [];
await repository.SyncSupportCasesAsync(
cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId, c.ClosedBy)),
cases.Select(c => new HubSupportCase(c.Reference, c.Title, c.IsOpen, c.CreatedAt.UtcDateTime, c.ClosedAt?.UtcDateTime, c.Source, c.ClientRequestId)),
time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
_casesFetchedAt = time.GetUtcNow();
}

View file

@ -30,7 +30,6 @@ public sealed partial class InstanceMonitor(
private readonly Dictionary<string, object> _previous = [];
private readonly Dictionary<string, DateTimeOffset> _nextDue = [];
private DateTimeOffset _nextBaseline = DateTimeOffset.MinValue;
private (InstanceInfo Info, int InstanceId)? _last;
/// <summary>Metrics a baseline is kept for. Must match what the repository stores (see <see cref="Metrics"/>).</summary>
public static readonly string[] BaselineMetrics = [Metrics.CpuSqlServer, Metrics.BatchRequestsPerSecond, Metrics.PageLifeExpectancy];
@ -56,13 +55,6 @@ public sealed partial class InstanceMonitor(
_nextDue[collector.Name] = now + IntervalOf(collector);
}
// Only frequent samples are due: store them without probing the instance again or evaluating the rules.
if (_last is { } last && _wasReachable == true && due.All(c => c.IsSampling))
{
await SampleAsync(due, last, now, cancellationToken).ConfigureAwait(false);
return null;
}
OpenedConnection opened;
InstanceInfo info;
int instanceId;
@ -93,7 +85,6 @@ public sealed partial class InstanceMonitor(
LogLegacyFallback(logger, instance.Name);
}
_last = (info, instanceId);
var capabilities = InstanceCapabilities.From(info);
foreach (var collector in due)
{
@ -130,36 +121,6 @@ public sealed partial class InstanceMonitor(
}
}
/// <summary>
/// Runs sampling collectors with what the last full cycle learned about the instance. A failed connection is left
/// to the next full cycle to report.
/// </summary>
private async Task SampleAsync(List<ICollector> due, (InstanceInfo Info, int InstanceId) last, DateTimeOffset now, CancellationToken cancellationToken)
{
OpenedConnection opened;
try
{
opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogSampleConnectFailed(logger, instance.Name, ex.Message);
return;
}
await using (opened.ConfigureAwait(false))
{
var capabilities = InstanceCapabilities.From(last.Info);
foreach (var collector in due)
{
await CollectAsync(collector, opened, last.Info, capabilities, last.InstanceId, now, cancellationToken).ConfigureAwait(false);
}
}
}
[LoggerMessage(Level = LogLevel.Debug, Message = "{Instance}: sampling skipped, could not connect: {Error}")]
private static partial void LogSampleConnectFailed(ILogger logger, string instance, string error);
private async Task CollectAsync(
ICollector collector, OpenedConnection opened, InstanceInfo info, InstanceCapabilities capabilities, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
{
@ -229,23 +190,6 @@ public sealed partial class InstanceMonitor(
/// </summary>
private async Task<object?> SeedAsync(ICollector collector, int instanceId, DateTimeOffset now, CancellationToken cancellationToken)
{
// Changes are found against the stored state, so one made while the agent was down is still reported.
if (collector is Collectors.Instance.InstanceConfigurationCollector)
{
return await repository.GetSnapshotAsync<Core.Data.InstanceConfigurationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
if (collector is Collectors.Instance.PrivilegeCollector)
{
return await repository.GetSnapshotAsync<Core.Data.PrivilegeSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
// Earlier backup durations survive a restart, so a slow backup is still recognised.
if (collector is Collectors.Instance.LongOperationCollector)
{
return await repository.GetSnapshotAsync<Core.Data.LongOperationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
if (collector is not Collectors.Concurrency.DeadlockCollector)
{
return null;

View file

@ -20,8 +20,7 @@ public sealed partial class MonitoringService(
NotificationDispatcher notifications,
IOptions<NotificationOptions> notificationOptions,
Hub.HubOutbox hubOutbox,
Core.CustomRules.CustomRuleCatalog customRules,
Core.Data.IGroupMemberResolver groupResolver) : BackgroundService
Core.CustomRules.CustomRuleCatalog customRules) : BackgroundService
{
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
@ -32,7 +31,7 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
var monitors = settings.Instances
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent)), intervals, connectionFactory, ruleEngine, repository,
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
.ToList();
if (monitors.Count == 0)

View file

@ -96,18 +96,6 @@ builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(
builder.Services.AddHostedService<UpdateService>();
builder.Services.Configure<SQLInsight.Agent.Reports.ReportOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Reports.ReportOptions.SectionName));
builder.Services.AddHostedService<SQLInsight.Agent.Reports.MonthlyReportService>();
builder.Services.AddSingleton<SQLInsight.Core.Data.IGroupMemberResolver>(sp =>
{
if (!sp.GetRequiredService<IOptions<AgentOptions>>().Value.Privileges.ExpandWindowsGroups)
{
return new SQLInsight.Core.Data.NoGroupResolver("Udfoldning af Windows-grupper er slået fra (SQLInsight:Privileges:ExpandWindowsGroups).");
}
return OperatingSystem.IsWindows()
? new SQLInsight.Agent.Security.ActiveDirectoryGroupResolver(
sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<SQLInsight.Agent.Security.ActiveDirectoryGroupResolver>>())
: new SQLInsight.Core.Data.NoGroupResolver("Agenten kører ikke på Windows og kan derfor ikke spørge Active Directory.");
});
builder.Services.AddHostedService<MonitoringService>();
var host = builder.Build();

View file

@ -21,7 +21,6 @@
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
<PackageReference Include="System.DirectoryServices.AccountManagement" />
</ItemGroup>
<ItemGroup>
<!-- setup-repository gives the agent these rights on the monitored instances (Setup/RepositorySetup.cs). -->

View file

@ -1,114 +0,0 @@
using System.Collections.Concurrent;
using System.DirectoryServices.AccountManagement;
using System.Runtime.Versioning;
using System.Security.Principal;
using SQLInsight.Core.Data;
namespace SQLInsight.Agent.Security;
/// <summary>
/// Unfolds a Windows group (DOMAIN\Group) to the people in it, recursively, by asking Active Directory as the agent's
/// account (the computer account for the default virtual account, which may read the directory in a domain).
/// Answers are cached for an hour (failures for ten minutes) and each lookup is cut off after 20 seconds, so a slow or
/// unreachable domain controller never holds up the monitoring.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed partial class ActiveDirectoryGroupResolver(TimeProvider time, ILogger<ActiveDirectoryGroupResolver> logger) : IGroupMemberResolver
{
public static readonly TimeSpan CacheFor = TimeSpan.FromHours(1);
public static readonly TimeSpan CacheFailuresFor = TimeSpan.FromMinutes(10);
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20);
private readonly ConcurrentDictionary<string, (DateTimeOffset At, GroupExpansion Result)> _cache = new(StringComparer.OrdinalIgnoreCase);
public async Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(group);
var now = time.GetUtcNow();
if (_cache.TryGetValue(group, out var cached) && now - cached.At < (cached.Result.Error is null ? CacheFor : CacheFailuresFor))
{
return cached.Result;
}
GroupExpansion result;
try
{
result = await Task.Run(() => Expand(group), cancellationToken).WaitAsync(Timeout, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
result = GroupExpansion.Failed($"Active Directory svarede ikke inden for {Timeout.TotalSeconds:0} sekunder.");
}
catch (Exception ex) when (ex is PrincipalException or System.DirectoryServices.DirectoryServicesCOMException or System.Runtime.InteropServices.COMException
or InvalidOperationException or UnauthorizedAccessException or ArgumentException)
{
result = GroupExpansion.Failed($"Active Directory kunne ikke spørges: {ex.Message}");
}
if (result.Error is not null)
{
LogExpansionFailed(logger, group, result.Error);
}
_cache[group] = (now, result);
return result;
}
private static GroupExpansion Expand(string group)
{
var separator = group.IndexOf('\\', StringComparison.Ordinal);
if (separator <= 0)
{
return GroupExpansion.Failed("Gruppen har intet domæne (forventet DOMÆNE\\gruppe).");
}
var domain = group[..separator];
var name = group[(separator + 1)..];
if (domain.Equals("BUILTIN", StringComparison.OrdinalIgnoreCase) || domain.Equals("NT AUTHORITY", StringComparison.OrdinalIgnoreCase))
{
return GroupExpansion.Failed("En lokal gruppe på SQL Server-maskinen; medlemmerne kan ikke læses fra Active Directory.");
}
var local = domain.Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase);
using var context = local ? new PrincipalContext(ContextType.Machine) : new PrincipalContext(ContextType.Domain, domain);
using var principal = GroupPrincipal.FindByIdentity(context, IdentityType.SamAccountName, name);
if (principal is null)
{
return GroupExpansion.Failed(local ? "Gruppen findes ikke på maskinen." : $"Gruppen findes ikke i domænet {domain}.");
}
var members = new List<GroupMember>();
using var found = principal.GetMembers(recursive: true);
foreach (var member in found)
{
using (member)
{
var account = Account(member, domain);
var enabled = member is AuthenticablePrincipal authenticable ? authenticable.Enabled ?? true : true;
members.Add(new GroupMember(account, member.DisplayName, enabled));
}
}
return new GroupExpansion(members.DistinctBy(m => m.Account, StringComparer.OrdinalIgnoreCase).OrderBy(m => m.Account, StringComparer.OrdinalIgnoreCase).ToList());
}
/// <summary>DOMAIN\sam, from the SID so members of other domains get their own domain.</summary>
private static string Account(Principal member, string fallbackDomain)
{
try
{
if (member.Sid?.Translate(typeof(NTAccount)) is NTAccount account)
{
return account.Value;
}
}
catch (IdentityNotMappedException)
{
}
return $@"{fallbackDomain}\{member.SamAccountName}";
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Windows group {Group} could not be unfolded: {Error}")]
private static partial void LogExpansionFailed(ILogger logger, string group, string error);
}

View file

@ -8,25 +8,17 @@ namespace SQLInsight.Collectors;
public static class BuiltInCollectors
{
/// <param name="custom">Community and local rules whose queries are run by the custom-rules collector.</param>
/// <param name="settings">The customer's settings for collectors that need them; defaults when null.</param>
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null, CollectorSettings? settings = null)
{
settings ??= new CollectorSettings();
return
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null) =>
[
new ServerCollector(),
new ConfigurationCollector(),
new TraceFlagCollector(),
new InstanceConfigurationCollector(),
new PrivilegeCollector(settings.Groups, settings.MaxSysadminAccounts),
new DatabaseCollector(),
new SuspectPageCollector(),
new PlanCacheCollector(),
new WaitStatsCollector(),
new FileIoCollector(),
new QueryStatsCollector(settings.ActivityThresholdPercent),
new WaitSamplingCollector(),
new LongOperationCollector(),
new QueryStatsCollector(),
new PerformanceCounterCollector(),
new CpuCollector(),
new BlockingCollector(),
@ -38,14 +30,4 @@ public static class BuiltInCollectors
new AgentJobCollector(),
.. custom is null ? Array.Empty<ICollector>() : [new Custom.CustomQueryCollector(custom)],
];
}
}
/// <summary>Settings from the agent's configuration that some collectors need.</summary>
/// <param name="Groups">Unfolds Windows groups with server rights to the people in them (Active Directory).</param>
/// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
/// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param>
public sealed record CollectorSettings(
Core.Data.IGroupMemberResolver? Groups = null,
int MaxSysadminAccounts = 3,
double ActivityThresholdPercent = 50);

View file

@ -19,12 +19,6 @@ public interface ICollector
/// <summary>What the instance must support. If anything is missing, the collector does not run.</summary>
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
/// <summary>
/// A frequent, light sample (active session sampling): stored, but rules are not evaluated for it alone, so it can
/// run every few seconds without a full monitoring cycle.
/// </summary>
bool IsSampling => false;
Task<object> CollectAsync(CollectorContext context);
}
@ -47,7 +41,6 @@ public abstract class Collector<T> : ICollector
public Type DataType => typeof(T);
public abstract TimeSpan DefaultInterval { get; }
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
public virtual bool IsSampling => false;
async Task<object> ICollector.CollectAsync(CollectorContext context) =>
await CollectAsync(context, context.Previous as T).ConfigureAwait(false);

View file

@ -1,101 +0,0 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// The instance's setup for the configuration page and its change log: collation, services and accounts, Instant File
/// Initialization, Lock Pages in Memory, tempdb files and default paths, plus <c>sp_configure</c> and trace flags for
/// the fingerprint. Changes are computed against the previous collection, which the agent restores from the repository
/// after a restart, so a change made while the agent was down is still reported.
/// </summary>
public sealed class InstanceConfigurationCollector : Collector<InstanceConfigurationSnapshot>
{
// SERVERPROPERTY returns NULL for properties an older version does not know, so this runs everywhere.
internal const string PropertiesSql = """
SELECT CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)),
CAST(SERVERPROPERTY('Edition') AS nvarchar(128)), CAST(SERVERPROPERTY('Collation') AS nvarchar(128)),
CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS nvarchar(260)), CAST(SERVERPROPERTY('InstanceDefaultLogPath') AS nvarchar(260))
""";
// Which optional columns this build has: Instant File Initialization and the memory model came in service packs.
internal const string ColumnsSql = """
SELECT COL_LENGTH('sys.dm_server_services', 'instant_file_initialization_enabled'), COL_LENGTH('sys.dm_os_sys_info', 'sql_memory_model_desc')
""";
internal const string TempdbSql = """
SELECT name, type_desc, CAST(size AS bigint) / 128, growth, is_percent_growth, max_size
FROM sys.master_files WHERE database_id = 2 ORDER BY file_id
""";
public override string Name => "instance-config";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<InstanceConfigurationSnapshot> CollectAsync(CollectorContext context, InstanceConfigurationSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
var properties = (await Db.QueryAsync(connection, PropertiesSql, r => (Version: r.NullableString(0), Level: r.NullableString(1),
Edition: r.NullableString(2), Collation: r.NullableString(3), DataPath: r.NullableString(4), LogPath: r.NullableString(5)), ct).ConfigureAwait(false)).FirstOrDefault();
var snapshot = new InstanceConfigurationSnapshot
{
CapturedAt = context.Now,
ProductVersion = properties.Version,
ProductLevel = properties.Level,
Edition = properties.Edition,
Collation = properties.Collation,
DefaultDataPath = properties.DataPath,
DefaultLogPath = properties.LogPath,
};
if (context.Instance.Version.Major >= 9)
{
var tempdb = await Db.QueryAsync(connection, TempdbSql, r => new TempdbFile(
r.String(0), r.String(1), r.Long(2), Growth(r.Long(3), r.Bool(4)), MaxSize(r.Long(5))), ct).ConfigureAwait(false);
snapshot = snapshot with { TempdbFiles = tempdb };
}
if (context.Capabilities.Has(Capability.VolumeStats))
{
var columns = (await Db.QueryAsync(connection, ColumnsSql, r => (Ifi: r.NullableInt(0) is not null, Memory: r.NullableInt(1) is not null), ct).ConfigureAwait(false)).FirstOrDefault();
var ifi = columns.Ifi ? "instant_file_initialization_enabled" : "CAST(NULL AS nvarchar(1))";
var services = await Db.QueryAsync(connection,
$"SELECT servicename, service_account, startup_type_desc, status_desc, {ifi} FROM sys.dm_server_services",
r => new ServiceInfo(r.String(0), r.NullableString(1), r.NullableString(2), r.NullableString(3), YesNo(r.NullableString(4))), ct).ConfigureAwait(false);
var memoryModel = columns.Memory
? (await Db.QueryAsync(connection, "SELECT sql_memory_model_desc FROM sys.dm_os_sys_info", r => r.NullableString(0), ct).ConfigureAwait(false)).FirstOrDefault()
: null;
snapshot = snapshot with
{
Services = services,
ServicesAvailable = true,
InstantFileInitialization = services.FirstOrDefault(s => s.Name.StartsWith("SQL Server (", StringComparison.OrdinalIgnoreCase))?.InstantFileInitialization,
MemoryModel = memoryModel,
};
}
var options = await Db.QueryAsync(connection, context.Instance.Version.Major >= 9 ? ConfigurationCollector.Sql2005 : ConfigurationCollector.Sql2000,
r => new ConfigurationOption(r.String(0), r.Long(1), r.Long(2)), ct).ConfigureAwait(false);
var flags = await TraceFlagCollector.ReadGlobalFlagsAsync(connection, ct).ConfigureAwait(false);
var items = ConfigurationFingerprint.Items(snapshot, options, flags);
var changes = previous is null ? [] : ConfigurationFingerprint.Diff(previous.Items, items, context.Now);
var recent = (previous?.RecentChanges ?? []).Where(c => context.Now - c.DetectedAt < ConfigurationFingerprint.RecentWindow).Concat(changes).ToList();
return snapshot with { Items = items, Changes = changes, RecentChanges = recent };
}
private static string Growth(long growth, bool percent) =>
growth == 0 ? "ingen" : percent ? $"{growth} %" : $"{(growth / 128).ToString(CultureInfo.InvariantCulture)} MB";
private static string MaxSize(long maxSize) =>
maxSize is -1 or 268435456 ? "ubegrænset" : $"{(maxSize / 128).ToString(CultureInfo.InvariantCulture)} MB";
private static bool? YesNo(string? value) => value switch
{
"Y" or "y" => true,
"N" or "n" => false,
_ => null,
};
}

View file

@ -1,39 +0,0 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// Long operations SQL Server reports progress for (BACKUP, RESTORE, DBCC, ALTER INDEX REORGANIZE, SHRINK, ROLLBACK …):
/// command, database, start, percent complete, expected end and login, every minute, and how long earlier runs of the
/// same command took. Read only; SQL Server 2005+.
/// </summary>
public sealed class LongOperationCollector : Collector<LongOperationSnapshot>
{
// Times converted to UTC on the server; estimated_completion_time is in ms and may exceed int, so seconds.
internal const string Sql = """
SELECT r.session_id, r.command, DB_NAME(r.database_id), s.login_name,
DATEADD(minute, DATEDIFF(minute, GETDATE(), GETUTCDATE()), r.start_time),
CAST(r.percent_complete AS float),
CASE WHEN r.estimated_completion_time > 0 THEN DATEADD(second, CAST(r.estimated_completion_time / 1000 AS int), GETUTCDATE()) END,
GETUTCDATE()
FROM sys.dm_exec_requests r
LEFT JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
WHERE r.percent_complete > 0 AND r.session_id <> @@SPID
""";
public override string Name => "long-operations";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<LongOperationSnapshot> CollectAsync(CollectorContext context, LongOperationSnapshot? previous)
{
var running = await Db.QueryAsync(context.Connection, Sql, r => new LongOperation(
r.Int(0), r.String(1).Trim(), r.NullableString(2), r.NullableString(3),
Utc(r.DateTime(4)), r.Double(5), r.NullableDateTime(6) is { } end ? Utc(end) : null, Utc(r.DateTime(7))),
context.CancellationToken).ConfigureAwait(false);
return LongOperationHistory.Advance(previous, running, context.Now);
}
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}

View file

@ -1,154 +0,0 @@
using System.Data.Common;
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// Members of the fixed server roles and holders of CONTROL SERVER, with Windows groups unfolded through
/// <see cref="IGroupMemberResolver"/>, SQL logins' password policy, and the changes since the previous collection.
/// Needs only VIEW ANY DEFINITION. Who made a change and when comes from the default trace, which needs ALTER TRACE;
/// without it the changes are still found, just without that.
/// </summary>
public sealed class PrivilegeCollector(IGroupMemberResolver? groups = null, int maxSysadminAccounts = 3) : Collector<PrivilegeSnapshot>
{
internal const string MembersSql = """
SELECT p.name, p.type, r.name, p.is_disabled
FROM sys.server_role_members m
JOIN sys.server_principals r ON r.principal_id = m.role_principal_id
JOIN sys.server_principals p ON p.principal_id = m.member_principal_id
WHERE r.name IN (N'sysadmin', N'securityadmin', N'serveradmin', N'setupadmin', N'processadmin', N'diskadmin', N'dbcreator', N'bulkadmin')
UNION ALL
SELECT p.name, p.type, N'CONTROL SERVER', p.is_disabled
FROM sys.server_permissions sp
JOIN sys.server_principals p ON p.principal_id = sp.grantee_principal_id
WHERE sp.permission_name = N'CONTROL SERVER' AND sp.state IN ('G', 'W')
""";
internal const string SqlLoginsSql = """
SELECT name, is_disabled, is_policy_checked, is_expiration_checked, CASE WHEN principal_id = 1 THEN 1 ELSE 0 END
FROM sys.sql_logins WHERE name NOT LIKE N'##%'
""";
internal const string DefaultTraceEnabledSql = """
SELECT CAST(value_in_use AS int) FROM sys.configurations WHERE name = N'default trace enabled'
""";
// sys.traces and fn_trace_gettable need ALTER TRACE.
internal const string DefaultTracePathSql = """
SELECT path, DATEDIFF(minute, GETUTCDATE(), GETDATE()) FROM sys.traces WHERE is_default = 1
""";
private readonly IGroupMemberResolver _groups = groups ?? new NoGroupResolver();
public override string Name => "privileges";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<PrivilegeSnapshot> CollectAsync(CollectorContext context, PrivilegeSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
var rows = await Db.QueryAsync(connection, MembersSql, r => (Name: r.String(0), Type: r.String(1), Privilege: r.String(2), Disabled: r.Bool(3)), ct)
.ConfigureAwait(false);
var principals = new List<PrivilegedPrincipal>();
foreach (var row in rows)
{
var type = TypeName(row.Type);
var principal = new PrivilegedPrincipal(row.Name, type, row.Privilege, row.Disabled);
if (type == "Windows-gruppe")
{
var expansion = await _groups.ExpandAsync(row.Name, ct).ConfigureAwait(false);
principal = expansion.Error is null
? principal with { Members = expansion.Members }
: principal with { ExpansionError = expansion.Error };
}
principals.Add(principal);
}
var logins = await Db.QueryAsync(connection, SqlLoginsSql, r => new SqlLoginInfo(r.String(0), r.Bool(1), r.Bool(2), r.Bool(3), r.Bool(4)), ct)
.ConfigureAwait(false);
var traceEnabled = (await Db.QueryAsync(connection, DefaultTraceEnabledSql, r => r.Int(0), ct).ConfigureAwait(false)).FirstOrDefault() == 1;
var snapshot = new PrivilegeSnapshot
{
CapturedAt = context.Now,
Principals = principals,
SqlLogins = logins,
DefaultTraceEnabled = traceEnabled,
MaxSysadminAccounts = maxSysadminAccounts,
};
if (previous is null)
{
return snapshot;
}
var changes = PrivilegeAnalysis.Diff(previous, snapshot, context.Now);
var recent = previous.RecentChanges.Where(c => context.Now - c.DetectedAt < PrivilegeAnalysis.RecentWindow).ToList();
// The default trace is written in buffers: a change made just before the previous collection may only be in it now.
var unknown = recent.Where(PrivilegeAnalysis.CanFindWho).ToList();
var traceStatus = previous.TraceStatus;
if (changes.Count > 0 || unknown.Count > 0)
{
var since = unknown.Select(c => c.DetectedAt).Append(previous.CapturedAt).Min().AddHours(-1);
(var events, traceStatus) = await ReadTraceAsync(connection, traceEnabled, since, ct).ConfigureAwait(false);
changes = PrivilegeAnalysis.Enrich(changes, events);
recent = [.. PrivilegeAnalysis.Enrich(recent, events)];
}
return snapshot with { Changes = changes, RecentChanges = [.. recent, .. changes], TraceStatus = traceStatus };
}
/// <summary>Role membership events from the default trace since <paramref name="since"/>, or why they cannot be read.</summary>
private static async Task<(IReadOnlyList<TraceSecurityEvent> Events, string? Status)> ReadTraceAsync(
DbConnection connection, bool enabled, DateTimeOffset since, CancellationToken cancellationToken)
{
if (!enabled)
{
return ([], "Default trace er slået fra, så det kan ikke ses, hvem der ændrede rettighederne.");
}
try
{
var trace = (await Db.QueryAsync(connection, DefaultTracePathSql, r => (Path: r.NullableString(0), Offset: r.Int(1)), cancellationToken)
.ConfigureAwait(false)).FirstOrDefault();
if (trace.Path is null)
{
return ([], "Default trace kører ikke.");
}
var offset = TimeSpan.FromMinutes(trace.Offset);
var sinceLocal = (since.UtcDateTime + offset).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
var sql = $"""
SELECT StartTime, EventClass, EventSubClass, LoginName, TargetLoginName, RoleName
FROM sys.fn_trace_gettable({Db.Literal(PrivilegeAnalysis.TraceFileBase(trace.Path))}, DEFAULT)
WHERE EventClass IN (104, 105, 108) AND StartTime >= '{sinceLocal}'
""";
var events = await Db.QueryAsync(connection, sql, r => new TraceSecurityEvent(
new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(0) - offset, DateTimeKind.Utc)),
r.Int(1), r.NullableInt(2) ?? 0, r.NullableString(3), r.NullableString(4), r.NullableString(5)), cancellationToken).ConfigureAwait(false);
return (events, null);
}
catch (DbException ex)
{
// Typically: no ALTER TRACE (grant-least-privilege.sql leaves it out unless chosen).
return ([], $"Default trace kunne ikke læses ({ex.Message.Split('\n')[0].Trim()}). Giv agenten ALTER TRACE for at se, hvem der ændrede rettighederne (se grant-least-privilege.sql).");
}
}
private static string TypeName(string type) => type.Trim() switch
{
"S" => "SQL-login",
"U" => "Windows-login",
"G" => "Windows-gruppe",
"R" => "Serverrolle",
"C" => "Certifikat",
"K" => "Nøgle",
_ => "Andet",
};
}

View file

@ -1,4 +1,3 @@
using System.Data.Common;
using SQLInsight.Core.Data;
namespace SQLInsight.Collectors.Instance;
@ -8,19 +7,15 @@ public sealed class TraceFlagCollector : Collector<TraceFlagSnapshot>
public override string Name => "trace-flags";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
protected override async Task<TraceFlagSnapshot> CollectAsync(CollectorContext context, TraceFlagSnapshot? previous) =>
new(context.Now, await ReadGlobalFlagsAsync(context.Connection, context.CancellationToken).ConfigureAwait(false));
/// <summary>The globally enabled trace flags, in order.</summary>
internal static async Task<IReadOnlyList<int>> ReadGlobalFlagsAsync(DbConnection connection, CancellationToken cancellationToken)
protected override async Task<TraceFlagSnapshot> CollectAsync(CollectorContext context, TraceFlagSnapshot? previous)
{
// 2005+: TraceFlag, Status, Global, Session. 2000: TraceFlag, Status (only global flags are listed with -1).
var rows = await Db.QueryAsync(
connection,
context.Connection,
"DBCC TRACESTATUS(-1) WITH NO_INFOMSGS",
r => (Flag: r.Int(0), Enabled: r.Bool(1), Global: r.FieldCount < 3 || r.Bool(2)),
cancellationToken).ConfigureAwait(false);
context.CancellationToken).ConfigureAwait(false);
return rows.Where(r => r.Enabled && r.Global).Select(r => r.Flag).Distinct().Order().ToList();
return new TraceFlagSnapshot(context.Now, rows.Where(r => r.Enabled && r.Global).Select(r => r.Flag).Distinct().Order().ToList());
}
}

View file

@ -8,25 +8,12 @@ public sealed class FileIoCollector : Collector<CumulativeSample<FileIoSnapshot,
internal const string Sql = """
SELECT DB_NAME(vfs.database_id), vfs.file_id, mf.type_desc, mf.physical_name,
vfs.num_of_reads, vfs.num_of_bytes_read, vfs.io_stall_read_ms,
vfs.num_of_writes, vfs.num_of_bytes_written, vfs.io_stall_write_ms,
CAST(NULL AS nvarchar(260))
vfs.num_of_writes, vfs.num_of_bytes_written, vfs.io_stall_write_ms
FROM sys.dm_io_virtual_file_stats(NULL, NULL) vfs
JOIN sys.master_files mf ON mf.database_id = vfs.database_id AND mf.file_id = vfs.file_id
WHERE mf.type_desc IN (N'ROWS', N'LOG')
""";
/// <summary>With the real volume (mount points included), from 2008 R2 SP1.</summary>
internal const string SqlWithVolumes = """
SELECT DB_NAME(vfs.database_id), vfs.file_id, mf.type_desc, mf.physical_name,
vfs.num_of_reads, vfs.num_of_bytes_read, vfs.io_stall_read_ms,
vfs.num_of_writes, vfs.num_of_bytes_written, vfs.io_stall_write_ms,
vs.volume_mount_point
FROM sys.dm_io_virtual_file_stats(NULL, NULL) vfs
JOIN sys.master_files mf ON mf.database_id = vfs.database_id AND mf.file_id = vfs.file_id
OUTER APPLY sys.dm_os_volume_stats(vfs.database_id, vfs.file_id) vs
WHERE mf.type_desc IN (N'ROWS', N'LOG')
""";
public override string Name => "file-io";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
@ -36,24 +23,18 @@ public sealed class FileIoCollector : Collector<CumulativeSample<FileIoSnapshot,
{
var ct = context.CancellationToken;
var startTime = await Db.StartTimeAsync(context.Connection, context.Instance.Version, ct).ConfigureAwait(false);
var sql = context.Capabilities.Has(Capability.VolumeStats) ? SqlWithVolumes : Sql;
var files = await Db.QueryAsync(context.Connection, sql, r =>
{
var physicalName = r.String(3);
return new FileIoStat
var files = await Db.QueryAsync(context.Connection, Sql, r => new FileIoStat
{
DatabaseName = r.NullableString(0) ?? "?",
FileId = r.Int(1),
FileType = r.String(2),
PhysicalName = physicalName,
PhysicalName = r.String(3),
Reads = r.Long(4),
BytesRead = r.Long(5),
ReadStallMs = r.Long(6),
Writes = r.Long(7),
BytesWritten = r.Long(8),
WriteStallMs = r.Long(9),
Volume = r.NullableString(10) is { Length: > 0 } volume ? volume : DiskVolumes.FromPhysicalName(physicalName),
};
}, ct).ConfigureAwait(false);
var snapshot = new FileIoSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Files = files };

View file

@ -10,7 +10,7 @@ namespace SQLInsight.Collectors.Performance;
/// the heaviest <see cref="TopPerMeasure"/> statements by CPU, duration, reads and executions, their text cut to
/// <see cref="QueryStat.MaxTextLength"/> characters, and no plan XML.
/// </summary>
public sealed class QueryStatsCollector(double activityThresholdPercent = 50) : Collector<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>
public sealed class QueryStatsCollector : Collector<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>
{
public const int TopPerMeasure = 50;
@ -108,11 +108,7 @@ public sealed class QueryStatsCollector(double activityThresholdPercent = 50) :
// No statement ran since the previous collection: the server clock still has to move forward.
serverTime ??= (await Db.QueryAsync(context.Connection, "SELECT GETDATE()", r => r.DateTime(0), ct).ConfigureAwait(false))[0];
var fetched = new QueryStatsSnapshot
{
CapturedAt = context.Now, ServerTime = serverTime.Value, SqlServerStartTime = startTime, Queries = rows,
ActivityThresholdPercent = activityThresholdPercent,
};
var fetched = new QueryStatsSnapshot { CapturedAt = context.Now, ServerTime = serverTime.Value, SqlServerStartTime = startTime, Queries = rows };
var delta = QueryStatsDelta.Compute(previous?.Snapshot, fetched);
var kept = fetched with { Queries = QueryStatsDelta.Remember(previous?.Snapshot, rows, startTime) };
return new(kept, delta);

View file

@ -1,54 +0,0 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Performance;
/// <summary>
/// Active session sampling: every 15 seconds, what each waiting user request waits on, in which database and for
/// which statement (the same key as top SQL). Sums of these samples show which statements a wait type comes from.
/// The agent's own sessions and system sessions are left out. One light query on sys.dm_exec_requests; SQL Server 2005+.
/// </summary>
public sealed class WaitSamplingCollector : Collector<WaitSampleSnapshot>
{
private const string From = """
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) st
WHERE s.is_user_process = 1 AND r.session_id <> @@SPID AND r.wait_type IS NOT NULL
AND (s.program_name IS NULL OR s.program_name NOT LIKE N'SQLInsight Pro%')
""";
private const string Columns = """
r.session_id, r.wait_type, r.wait_time, DB_NAME(r.database_id), r.blocking_session_id,
""";
private const string Text = """
CAST(SUBSTRING(st.text, r.statement_start_offset / 2 + 1,
CASE WHEN r.statement_end_offset = -1 THEN 4000 ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar(4000))
""";
/// <summary>The statement key as top SQL makes it on 2008+: query_hash.</summary>
internal const string Sql2008 = "SELECT " + Columns + """
CASE WHEN r.query_hash IS NULL OR r.query_hash = 0x0000000000000000 THEN NULL ELSE CONVERT(varchar(20), r.query_hash, 1) END,
""" + "\n" + Text + "\n" + From;
/// <summary>SQL Server 2005 has no query_hash: the batch (sql_handle) and the statement's offsets, as top SQL.</summary>
internal const string Sql2005 = "SELECT " + Columns + """
CASE WHEN r.sql_handle IS NULL THEN NULL
ELSE sys.fn_varbintohexstr(r.sql_handle) + ':' + CAST(r.statement_start_offset AS varchar(12)) + ':' + CAST(r.statement_end_offset AS varchar(12)) END,
""" + "\n" + Text + "\n" + From;
public override string Name => "wait-sampling";
public override TimeSpan DefaultInterval => WaitSampling.Interval;
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override bool IsSampling => true;
protected override async Task<WaitSampleSnapshot> CollectAsync(CollectorContext context, WaitSampleSnapshot? previous)
{
var sql = context.Instance.Version.Major >= 10 ? Sql2008 : Sql2005;
var requests = await Db.QueryAsync(context.Connection, sql, r => new WaitingRequest(
r.Int(0), r.String(1), r.Long(2), r.NullableString(3), r.NullableString(5), r.NullableString(6), r.NullableInt(4) ?? 0),
context.CancellationToken).ConfigureAwait(false);
return WaitSampling.Aggregate(requests, context.Now);
}
}

View file

@ -70,9 +70,7 @@ public sealed record SupportCaseDto(
DateTimeOffset CreatedAt,
DateTimeOffset? ClosedAt,
string Source,
Guid? ClientRequestId,
string? ClosedBy = null,
string? ClosedComment = null);
Guid? ClientRequestId);
/// <summary>Who created a support case.</summary>
public static class SupportCaseSources
@ -83,15 +81,3 @@ public static class SupportCaseSources
/// <summary>The customer, in SQLInsight Web.</summary>
public const string Customer = "Customer";
}
/// <summary>
/// The customer closes a case (POST /api/v1/cases/{reference}/close), from SQLInsight Web or its API. Closing a closed
/// case changes nothing.
/// </summary>
public sealed record SupportCaseCloseRequest(string? Comment, string? RequestedBy)
{
public const int MaxComment = 1000;
public string? Validate() => Comment?.Length > MaxComment ? $"Kommentaren må højst være {MaxComment} tegn."
: RequestedBy?.Length > SupportCaseRequest.MaxRequestedBy ? "Brugernavnet er for langt." : null;
}

View file

@ -19,13 +19,6 @@ public sealed record FileIoStat
public required string FileType { get; init; }
public required string PhysicalName { get; init; }
/// <summary>
/// The volume (drive or mount point) the file lives on, e.g. <c>D:\</c>. From <c>sys.dm_os_volume_stats</c> where
/// the instance has it, otherwise derived from the path (<see cref="DiskVolumes.FromPhysicalName"/>).
/// </summary>
public string? Volume { get; init; }
public required long Reads { get; init; }
public required long BytesRead { get; init; }
public required long ReadStallMs { get; init; }
@ -37,69 +30,6 @@ public sealed record FileIoStat
public double? AverageWriteLatencyMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
}
/// <summary>
/// Which drive a database file is on, when SQL Server cannot say (before 2008 R2 SP1 there is no
/// <c>sys.dm_os_volume_stats</c>).
/// </summary>
public static class DiskVolumes
{
/// <summary>
/// <c>D:\Data\x.mdf</c> → <c>D:\</c>; <c>\\nas\sql\x.mdf</c> → <c>\\nas\sql\</c>; a Linux path → <c>/</c>. A mount point
/// under a drive letter cannot be told apart from the drive itself this way.
/// </summary>
public static string FromPhysicalName(string physicalName)
{
ArgumentNullException.ThrowIfNull(physicalName);
var path = physicalName.Trim();
if (path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':')
{
return char.ToUpperInvariant(path[0]) + @":\";
}
if (path.StartsWith(@"\\", StringComparison.Ordinal))
{
var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}\" : path;
}
return path.StartsWith('/') ? "/" : path;
}
/// <summary>The file's volume as reported, or derived from its path.</summary>
public static string Of(FileIoStat file)
{
ArgumentNullException.ThrowIfNull(file);
return string.IsNullOrWhiteSpace(file.Volume) ? FromPhysicalName(file.PhysicalName) : file.Volume;
}
}
/// <summary>
/// I/O on one drive over an interval: the sum of its database files. Latency is weighted by the number of I/Os.
/// </summary>
public sealed record DiskActivity(string Volume, int DataFiles, int LogFiles, long Reads, long Writes, long BytesRead, long BytesWritten,
long ReadStallMs, long WriteStallMs, double Seconds)
{
public double? ReadIops => Seconds > 0 ? Reads / Seconds : null;
public double? WriteIops => Seconds > 0 ? Writes / Seconds : null;
public double? ReadMbPerSecond => Seconds > 0 ? BytesRead / 1048576.0 / Seconds : null;
public double? WriteMbPerSecond => Seconds > 0 ? BytesWritten / 1048576.0 / Seconds : null;
public double? AverageReadMs => Reads == 0 ? null : (double)ReadStallMs / Reads;
public double? AverageWriteMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
/// <summary>Groups the files of one interval by drive.</summary>
public static IReadOnlyList<DiskActivity> ByVolume(IEnumerable<FileIoStat> files, double seconds)
{
ArgumentNullException.ThrowIfNull(files);
return files
.GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase)
.Select(g => new DiskActivity(g.Key, g.Count(f => f.FileType != "LOG"), g.Count(f => f.FileType == "LOG"),
g.Sum(f => f.Reads), g.Sum(f => f.Writes), g.Sum(f => f.BytesRead), g.Sum(f => f.BytesWritten),
g.Sum(f => f.ReadStallMs), g.Sum(f => f.WriteStallMs), seconds))
.OrderBy(d => d.Volume, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
/// <summary>
/// I/O per file between two snapshots.
/// </summary>

View file

@ -1,198 +0,0 @@
using System.Globalization;
namespace SQLInsight.Core.Data;
/// <summary>
/// How the instance is set up, beyond <c>sp_configure</c>: collation, services and their accounts, Instant File
/// Initialization, Lock Pages in Memory, tempdb files and default paths. Also a flat "fingerprint" of the whole setup
/// (including <c>sp_configure</c> and trace flags), so changes between two collections can be listed.
/// </summary>
public sealed record InstanceConfigurationSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public string? ProductVersion { get; init; }
public string? ProductLevel { get; init; }
public string? Edition { get; init; }
public string? Collation { get; init; }
/// <summary>SQL Server and SQL Server Agent services (<c>sys.dm_server_services</c>, 2008 R2 SP1+); empty before.</summary>
public IReadOnlyList<ServiceInfo> Services { get; init; } = [];
/// <summary>Whether the services could be read at all (the DMV exists).</summary>
public bool ServicesAvailable { get; init; }
/// <summary>Instant File Initialization for the database engine service (2012 SP4, 2016 SP1+); null when unknown.</summary>
public bool? InstantFileInitialization { get; init; }
/// <summary>CONVENTIONAL, LOCK_PAGES or LARGE_PAGES (2012 SP4, 2016 SP1+); null when unknown.</summary>
public string? MemoryModel { get; init; }
public IReadOnlyList<TempdbFile> TempdbFiles { get; init; } = [];
/// <summary>Default data and log folders for new databases (2012+); null when unknown.</summary>
public string? DefaultDataPath { get; init; }
public string? DefaultLogPath { get; init; }
/// <summary>The setup as comparable items: what <see cref="Changes"/> is computed from.</summary>
public IReadOnlyList<ConfigurationItem> Items { get; init; } = [];
/// <summary>What changed since the previous collection (empty on the first).</summary>
public IReadOnlyList<ConfigurationChange> Changes { get; init; } = [];
/// <summary>Changes detected within the last <see cref="ConfigurationFingerprint.RecentWindow"/>, for the finding.</summary>
public IReadOnlyList<ConfigurationChange> RecentChanges { get; init; } = [];
}
public sealed record ServiceInfo(string Name, string? Account, string? StartupType, string? Status, bool? InstantFileInitialization);
/// <param name="Growth">"64 MB" or "10 %"; "ingen" when autogrowth is off.</param>
/// <param name="MaxSize">"ubegrænset" or a size.</param>
public sealed record TempdbFile(string Name, string Type, long SizeMb, string Growth, string MaxSize);
/// <param name="Area">sp_configure, Trace flag, Tjenestekonto, Instans, Tempdb, Hukommelse.</param>
public sealed record ConfigurationItem(string Area, string Name, string Value);
/// <param name="OldValue">Null when the item is new.</param>
/// <param name="NewValue">Null when the item was removed (a trace flag turned off, a service gone).</param>
public sealed record ConfigurationChange(string Area, string Name, string? OldValue, string? NewValue, DateTimeOffset DetectedAt);
public static class ConfigurationFingerprint
{
public const string SpConfigure = "sp_configure";
public const string TraceFlag = "Trace flag";
public const string ServiceAccount = "Tjenestekonto";
public const string Instance = "Instans";
public const string Tempdb = "Tempdb";
public const string Memory = "Hukommelse";
/// <summary>How long a change keeps its "Konfiguration ændret" finding open.</summary>
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
/// <summary>Areas whose changes are findings: settings someone deliberately changed.</summary>
public static bool IsNotable(string area) => area is SpConfigure or TraceFlag or ServiceAccount;
/// <summary>Items present, changed or removed between two fingerprints. Order: area, then name.</summary>
public static IReadOnlyList<ConfigurationChange> Diff(IReadOnlyList<ConfigurationItem> before, IReadOnlyList<ConfigurationItem> after, DateTimeOffset at)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var old = before.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var now = after.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var changes = new List<ConfigurationChange>();
foreach (var (key, item) in now)
{
if (!old.TryGetValue(key, out var was))
{
changes.Add(new ConfigurationChange(item.Area, item.Name, null, item.Value, at));
}
else if (!string.Equals(was.Value, item.Value, StringComparison.Ordinal))
{
changes.Add(new ConfigurationChange(item.Area, item.Name, was.Value, item.Value, at));
}
}
changes.AddRange(old.Where(o => !now.ContainsKey(o.Key)).Select(o => new ConfigurationChange(o.Value.Area, o.Value.Name, o.Value.Value, null, at)));
return changes.OrderBy(c => c.Area, StringComparer.Ordinal).ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
static string Key(ConfigurationItem i) => i.Area + "\u001f" + i.Name;
}
/// <summary>The fingerprint of sp_configure, trace flags and the instance-level settings.</summary>
public static IReadOnlyList<ConfigurationItem> Items(InstanceConfigurationSnapshot instance, IReadOnlyList<ConfigurationOption> options, IReadOnlyList<int> traceFlags)
{
ArgumentNullException.ThrowIfNull(instance);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(traceFlags);
var items = new List<ConfigurationItem>();
items.AddRange(options.Select(o => new ConfigurationItem(SpConfigure, o.Name, o.ValueInUse.ToString(CultureInfo.InvariantCulture))));
items.AddRange(traceFlags.Select(f => new ConfigurationItem(TraceFlag, f.ToString(CultureInfo.InvariantCulture), "til")));
items.AddRange(instance.Services.Select(s => new ConfigurationItem(ServiceAccount, s.Name, s.Account ?? "?")));
Add(Instance, "Version", instance.ProductVersion is null ? null : $"{instance.ProductVersion} {instance.ProductLevel}".Trim());
Add(Instance, "Edition", instance.Edition);
Add(Instance, "Collation", instance.Collation);
if (instance.TempdbFiles.Count > 0)
{
Add(Tempdb, "Antal datafiler", instance.TempdbFiles.Count(f => f.Type != "LOG").ToString(CultureInfo.InvariantCulture));
}
Add(Memory, "Lock Pages in Memory", instance.MemoryModel);
Add(Memory, "Instant File Initialization", instance.InstantFileInitialization switch { true => "til", false => "fra", null => null });
return items;
void Add(string area, string name, string? value)
{
if (!string.IsNullOrEmpty(value))
{
items.Add(new ConfigurationItem(area, name, value));
}
}
}
/// <summary>
/// SQL Server's default for an sp_configure option, so non-default values can be shown; null when not known.
/// Defaults are the documented ones for the database engine (the same across 2005–2022 unless noted).
/// </summary>
public static long? DefaultValue(string option) => Defaults.TryGetValue(option, out var value) ? value : null;
private static readonly Dictionary<string, long> Defaults = new(StringComparer.OrdinalIgnoreCase)
{
["access check cache bucket count"] = 0,
["access check cache quota"] = 0,
["Ad Hoc Distributed Queries"] = 0,
["affinity I/O mask"] = 0,
["affinity mask"] = 0,
["allow updates"] = 0,
["backup checksum default"] = 0,
["backup compression default"] = 0,
["blocked process threshold (s)"] = 0,
["c2 audit mode"] = 0,
["clr enabled"] = 0,
["clr strict security"] = 1,
["common criteria compliance enabled"] = 0,
["contained database authentication"] = 0,
["cost threshold for parallelism"] = 5,
["cross db ownership chaining"] = 0,
["cursor threshold"] = -1,
["Database Mail XPs"] = 0,
["default full-text language"] = 1033,
["default trace enabled"] = 1,
["disallow results from triggers"] = 0,
["external scripts enabled"] = 0,
["filestream access level"] = 0,
["fill factor (%)"] = 0,
["index create memory (KB)"] = 0,
["lightweight pooling"] = 0,
["locks"] = 0,
["max degree of parallelism"] = 0,
["max full-text crawl range"] = 4,
["max server memory (MB)"] = 2147483647,
["max text repl size (B)"] = 65536,
["max worker threads"] = 0,
["min memory per query (KB)"] = 1024,
["min server memory (MB)"] = 0,
["nested triggers"] = 1,
["network packet size (B)"] = 4096,
["Ole Automation Procedures"] = 0,
["open objects"] = 0,
["optimize for ad hoc workloads"] = 0,
["priority boost"] = 0,
["query governor cost limit"] = 0,
["query wait (s)"] = -1,
["recovery interval (min)"] = 0,
["remote access"] = 1,
["remote admin connections"] = 0,
["remote login timeout (s)"] = 10,
["remote proc trans"] = 0,
["remote query timeout (s)"] = 600,
["scan for startup procs"] = 0,
["server trigger recursion"] = 1,
["show advanced options"] = 0,
["SMO and DMO XPs"] = 1,
["transform noise words"] = 0,
["two digit year cutoff"] = 2049,
["user connections"] = 0,
["user options"] = 0,
["xp_cmdshell"] = 0,
};
}

View file

@ -1,90 +0,0 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// An operation SQL Server reports progress for (percent_complete in sys.dm_exec_requests): BACKUP, RESTORE, DBCC,
/// ALTER INDEX REORGANIZE, SHRINK, ROLLBACK and the like. Times are UTC. <see cref="LoginName"/> is sensitive.
/// </summary>
public sealed record LongOperation(
int SessionId,
string Command,
string? DatabaseName,
string? LoginName,
DateTimeOffset StartedAt,
double PercentComplete,
DateTimeOffset? EstimatedEnd,
DateTimeOffset LastSeenAt)
{
/// <summary>Identifies the operation across samples.</summary>
public string Key => $"{SessionId}|{StartedAt.UtcTicks}|{Command}";
/// <summary>Operations compared with each other: the same command on the same database.</summary>
public string Kind => $"{Command}|{DatabaseName}";
public TimeSpan Elapsed(DateTimeOffset now) => now - StartedAt;
}
/// <summary>An operation that is no longer running, with how long it took (until it was last seen).</summary>
public sealed record CompletedOperation(string Command, string? DatabaseName, DateTimeOffset StartedAt, TimeSpan Duration, double LastPercentComplete);
public sealed record LongOperationSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<LongOperation> Running { get; init; }
/// <summary>Operations that finished since the previous collection.</summary>
public IReadOnlyList<CompletedOperation> Completed { get; init; } = [];
/// <summary>The latest <see cref="LongOperationHistory.Kept"/> durations per command and database, newest first.</summary>
public IReadOnlyDictionary<string, IReadOnlyList<CompletedOperation>> History { get; init; } = new Dictionary<string, IReadOnlyList<CompletedOperation>>();
}
public static class LongOperationHistory
{
/// <summary>Earlier runs kept per command and database.</summary>
public const int Kept = 10;
/// <summary>Operations this short are not kept: a backup of an empty database says nothing about the next.</summary>
public static readonly TimeSpan MinimumDuration = TimeSpan.FromMinutes(1);
/// <summary>Commands whose duration is compared with earlier runs.</summary>
public static bool IsBackupOrRestore(string command)
{
ArgumentNullException.ThrowIfNull(command);
return command.StartsWith("BACKUP", StringComparison.OrdinalIgnoreCase) || command.StartsWith("RESTORE", StringComparison.OrdinalIgnoreCase);
}
/// <summary>The operations that are gone since the previous sample, and the history with them added.</summary>
public static LongOperationSnapshot Advance(LongOperationSnapshot? previous, IReadOnlyList<LongOperation> running, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(running);
var still = running.Select(r => r.Key).ToHashSet(StringComparer.Ordinal);
var completed = (previous?.Running ?? [])
.Where(p => !still.Contains(p.Key))
.Select(p => new CompletedOperation(p.Command, p.DatabaseName, p.StartedAt, p.LastSeenAt - p.StartedAt, p.PercentComplete))
.ToList();
var history = (previous?.History ?? new Dictionary<string, IReadOnlyList<CompletedOperation>>())
.ToDictionary(h => h.Key, h => h.Value, StringComparer.Ordinal);
foreach (var c in completed.Where(c => c.Duration >= MinimumDuration))
{
var key = $"{c.Command}|{c.DatabaseName}";
history[key] = new[] { c }.Concat(history.GetValueOrDefault(key) ?? []).Take(Kept).ToList();
}
return new LongOperationSnapshot { CapturedAt = now, Running = running, Completed = completed, History = history };
}
/// <summary>The median duration of earlier runs of the same kind; null with fewer than three.</summary>
public static TimeSpan? Median(LongOperationSnapshot snapshot, LongOperation operation)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(operation);
if (!snapshot.History.TryGetValue(operation.Kind, out var runs) || runs.Count < 3)
{
return null;
}
var sorted = runs.Select(r => r.Duration.Ticks).Order().ToList();
var middle = sorted.Count / 2;
return TimeSpan.FromTicks(sorted.Count % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2);
}
}

View file

@ -1,234 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace SQLInsight.Core.Data;
/// <summary>
/// Who has elevated rights on the instance: members of the fixed server roles and holders of CONTROL SERVER, with
/// Windows groups unfolded to the people in them (from Active Directory, by the agent), SQL logins' password policy,
/// and what changed since the previous collection. Names are customer data: they stay in the customer's repository and
/// are sensitive evidence in findings.
/// </summary>
public sealed record PrivilegeSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<PrivilegedPrincipal> Principals { get; init; }
/// <summary>All SQL logins (not only the privileged ones), for password policy and the sa account.</summary>
public IReadOnlyList<SqlLoginInfo> SqlLogins { get; init; } = [];
/// <summary>Whether the default trace is on (sp_configure 'default trace enabled'); it records who changed roles.</summary>
public bool? DefaultTraceEnabled { get; init; }
/// <summary>Why "who and when" could not be read from the default trace; null when it could (or was not needed).</summary>
public string? TraceStatus { get; init; }
/// <summary>More people and accounts with sysadmin than this is a finding (SQLInsight:Privileges:MaxSysadminAccounts).</summary>
public int MaxSysadminAccounts { get; init; } = 3;
/// <summary>Changes since the previous collection (empty on the first: that one is the starting point).</summary>
public IReadOnlyList<PrivilegeChange> Changes { get; init; } = [];
/// <summary>Changes detected within <see cref="PrivilegeAnalysis.RecentWindow"/>: they keep their findings open.</summary>
public IReadOnlyList<PrivilegeChange> RecentChanges { get; init; } = [];
}
/// <param name="Type">SQL-login, Windows-login, Windows-gruppe, Serverrolle, Certifikat or Nøgle.</param>
/// <param name="Privilege">The fixed server role (sysadmin …) or CONTROL SERVER.</param>
/// <param name="Members">The people in a Windows group (recursively); null when not a group or not unfolded.</param>
/// <param name="ExpansionError">Why a Windows group could not be unfolded.</param>
public sealed record PrivilegedPrincipal(string Name, string Type, string Privilege, bool IsDisabled,
IReadOnlyList<GroupMember>? Members = null, string? ExpansionError = null);
/// <param name="Account">DOMAIN\user.</param>
public sealed record GroupMember(string Account, string? DisplayName, bool Enabled);
public sealed record SqlLoginInfo(string Name, bool IsDisabled, bool PolicyChecked, bool ExpirationChecked, bool IsSa);
/// <param name="Kind">Tilføjet or Fjernet.</param>
/// <param name="ViaGroup">The Windows group a person got (or lost) the right through; null for a direct member.</param>
/// <param name="ChangedBy">Who made the change, from the default trace; null when unknown.</param>
/// <param name="ChangedAt">When the change was made, from the default trace (UTC); null when unknown.</param>
public sealed record PrivilegeChange(DateTimeOffset DetectedAt, string Kind, string Privilege, string Principal, string PrincipalType,
string? ViaGroup = null, string? ChangedBy = null, DateTimeOffset? ChangedAt = null);
/// <summary>An account that effectively holds a privilege, directly or through a Windows group.</summary>
/// <param name="Source">"direkte" or the Windows group's name.</param>
public sealed record PrivilegedAccount(string Account, string? DisplayName, string Type, string Source);
/// <summary>An audit event from the default trace (role membership, logins, grants).</summary>
/// <param name="StartTime">UTC.</param>
public sealed record TraceSecurityEvent(DateTimeOffset StartTime, int EventClass, int EventSubClass, string? LoginName, string? TargetLoginName, string? RoleName);
/// <summary>Unfolds a Windows group to the people in it. The agent's implementation asks Active Directory.</summary>
public interface IGroupMemberResolver
{
Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken);
}
/// <param name="Error">Why the group could not be unfolded; then <paramref name="Members"/> is empty.</param>
public sealed record GroupExpansion(IReadOnlyList<GroupMember> Members, string? Error = null)
{
public static GroupExpansion Failed(string error) => new([], error);
}
/// <summary>For agents that cannot ask Active Directory (Linux, or turned off).</summary>
public sealed class NoGroupResolver(string reason = "Windows-grupper foldes kun ud af en Windows-agent i domænet.") : IGroupMemberResolver
{
public Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken) => Task.FromResult(GroupExpansion.Failed(reason));
}
public static class PrivilegeAnalysis
{
public const string Sysadmin = "sysadmin";
public const string ControlServer = "CONTROL SERVER";
public const string Added = "Tilføjet";
public const string Removed = "Fjernet";
public const string Direct = "direkte";
/// <summary>The fixed server roles watched, and CONTROL SERVER.</summary>
public static readonly IReadOnlyList<string> Privileges =
[Sysadmin, "securityadmin", "serveradmin", "setupadmin", "processadmin", "diskadmin", "dbcreator", "bulkadmin", ControlServer];
/// <summary>How long a new privilege keeps its finding open.</summary>
public static readonly TimeSpan RecentWindow = TimeSpan.FromDays(7);
/// <summary>Rights that amount to (or can grant) full control of the instance.</summary>
public static bool IsHighRisk(string privilege) =>
privilege is Sysadmin or "securityadmin" or ControlServer;
/// <summary>
/// Service and machine identities, internal certificate logins: they hold rights but are not people
/// (NT SERVICE\…, NT AUTHORITY\…, ##MS_…##, gMSA and computer accounts ending in $).
/// </summary>
public static bool IsServiceIdentity(string name)
{
ArgumentNullException.ThrowIfNull(name);
return name.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("##", StringComparison.Ordinal)
|| name.EndsWith('$');
}
/// <summary>
/// The enabled accounts that hold a privilege: SQL and Windows logins directly, and the enabled people in Windows
/// groups that could be unfolded. Service identities are left out: they are not people.
/// </summary>
public static IReadOnlyList<PrivilegedAccount> Accounts(PrivilegeSnapshot snapshot, string privilege)
{
ArgumentNullException.ThrowIfNull(snapshot);
var accounts = new List<PrivilegedAccount>();
foreach (var p in snapshot.Principals.Where(p => p.Privilege == privilege && !p.IsDisabled))
{
if (p.Type == "Windows-gruppe")
{
accounts.AddRange((p.Members ?? []).Where(m => m.Enabled && !IsServiceIdentity(m.Account))
.Select(m => new PrivilegedAccount(m.Account, m.DisplayName, "Windows-bruger", p.Name)));
}
else if (p.Type is "SQL-login" or "Windows-login" && !IsServiceIdentity(p.Name))
{
accounts.Add(new PrivilegedAccount(p.Name, null, p.Type, Direct));
}
}
return accounts
.GroupBy(a => a.Account, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderBy(a => a.Source == Direct ? 0 : 1).First())
.OrderBy(a => a.Account, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>Enabled Windows groups with the privilege that could not be unfolded: their members are unknown.</summary>
public static IReadOnlyList<PrivilegedPrincipal> UnfoldedGroupsMissing(PrivilegeSnapshot snapshot, string privilege)
{
ArgumentNullException.ThrowIfNull(snapshot);
return snapshot.Principals.Where(p => p.Privilege == privilege && !p.IsDisabled && p.Type == "Windows-gruppe" && p.Members is null).ToList();
}
/// <summary>
/// Who got or lost a privilege: direct members by principal, and people in Windows groups by account (only for groups
/// unfolded both times, so a group Active Directory could not answer for does not look like everyone left).
/// </summary>
public static IReadOnlyList<PrivilegeChange> Diff(PrivilegeSnapshot previous, PrivilegeSnapshot current, DateTimeOffset at)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(current);
var changes = new List<PrivilegeChange>();
var before = previous.Principals.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var after = current.Principals.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
changes.AddRange(after.Where(a => !before.ContainsKey(a.Key)).Select(a => new PrivilegeChange(at, Added, a.Value.Privilege, a.Value.Name, a.Value.Type)));
changes.AddRange(before.Where(b => !after.ContainsKey(b.Key)).Select(b => new PrivilegeChange(at, Removed, b.Value.Privilege, b.Value.Name, b.Value.Type)));
foreach (var (key, group) in after.Where(a => a.Value.Members is not null))
{
if (!before.TryGetValue(key, out var was) || was.Members is null)
{
continue;
}
var old = was.Members.Where(m => m.Enabled).Select(m => m.Account).ToHashSet(StringComparer.OrdinalIgnoreCase);
var now = group.Members!.Where(m => m.Enabled).Select(m => m.Account).ToHashSet(StringComparer.OrdinalIgnoreCase);
changes.AddRange(now.Except(old, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)
.Select(m => new PrivilegeChange(at, Added, group.Privilege, m, "Windows-bruger", group.Name)));
changes.AddRange(old.Except(now, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)
.Select(m => new PrivilegeChange(at, Removed, group.Privilege, m, "Windows-bruger", group.Name)));
}
return changes;
static string Key(PrivilegedPrincipal p) => p.Privilege + "\u001f" + p.Name;
}
/// <summary>A direct server role change whose author is not known yet: the default trace may tell.</summary>
public static bool CanFindWho(PrivilegeChange change)
{
ArgumentNullException.ThrowIfNull(change);
return change.ChangedBy is null && change.ViaGroup is null && change.Privilege != ControlServer;
}
/// <summary>
/// Who changed a server role membership and when, from the default trace: the latest matching
/// "Audit Add Login to Server Role" event (108; subclass 1 add, 2 drop) before the change was detected.
/// </summary>
public static IReadOnlyList<PrivilegeChange> Enrich(IReadOnlyList<PrivilegeChange> changes, IReadOnlyList<TraceSecurityEvent> events)
{
ArgumentNullException.ThrowIfNull(changes);
ArgumentNullException.ThrowIfNull(events);
return changes.Select(c =>
{
if (!CanFindWho(c))
{
return c;
}
var subclass = c.Kind == Added ? 1 : 2;
var match = events
.Where(e => e.EventClass == AddLoginToServerRole && e.EventSubClass == subclass
&& string.Equals(e.RoleName, c.Privilege, StringComparison.OrdinalIgnoreCase)
&& string.Equals(e.TargetLoginName, c.Principal, StringComparison.OrdinalIgnoreCase)
&& e.StartTime <= c.DetectedAt.AddMinutes(1))
.MaxBy(e => e.StartTime);
return match is null ? c : c with { ChangedBy = match.LoginName, ChangedAt = match.StartTime };
}).ToList();
}
/// <summary>Default trace event "Audit Add Login to Server Role Event".</summary>
public const int AddLoginToServerRole = 108;
/// <summary>A stable, name-free key for a finding's subject (names must not reach the hub).</summary>
public static string Fingerprint(string value)
{
ArgumentNullException.ThrowIfNull(value);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToUpperInvariant()));
return Convert.ToHexString(hash, 0, 6).ToLowerInvariant();
}
/// <summary>Default trace files: the folder of the current file plus log.trc, so its rollover files are read too.</summary>
public static string TraceFileBase(string currentPath)
{
ArgumentNullException.ThrowIfNull(currentPath);
var separator = currentPath.LastIndexOfAny(['\\', '/']);
return separator < 0 ? "log.trc" : currentPath[..(separator + 1)] + "log.trc";
}
}

View file

@ -16,9 +16,6 @@ public sealed record QueryStatsSnapshot
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<QueryStat> Queries { get; init; }
/// <summary>A statement using more CPU time than this share of its time in the plan cache is a finding (SQLInsight:TopSql).</summary>
public double ActivityThresholdPercent { get; init; } = 50;
}
/// <summary>
@ -47,12 +44,6 @@ public sealed record QueryStat
/// <summary>last_execution_time (server clock).</summary>
public required DateTime LastExecutionAt { get; init; }
/// <summary>
/// CPU time as a share of the time the statement has been in the plan cache (creation to last execution), in
/// percent: 100 means one CPU core all the time, more is parallel. Null when it has been cached less than a minute.
/// </summary>
public double? ActivityPercent => LastExecutionAt - CreatedAt is { TotalSeconds: >= 60 } lifetime ? 100.0 * WorkerTimeUs / lifetime.TotalMicroseconds : null;
public const int MaxTextLength = 4000;
}

View file

@ -1,259 +0,0 @@
namespace SQLInsight.Core.Data;
/// <summary>What a wait type means, in Danish, for the customer. <see cref="Known"/> is false for the generic text.</summary>
/// <param name="Family">The wait types it covers, e.g. "LCK_M_*".</param>
/// <param name="Normal">When the wait is normal and nothing to act on.</param>
/// <param name="DynamicsAx">Notes for Dynamics AX / 365 F&amp;O installations; null when nothing specific.</param>
public sealed record WaitExplanation(
string Family,
string Title,
string Meaning,
string Normal,
string Causes,
string Actions,
string? DynamicsAx = null,
bool Known = true)
{
/// <summary>Microsoft's list of all wait types.</summary>
public static readonly Uri MicrosoftDocs = new("https://learn.microsoft.com/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql");
}
/// <summary>
/// Danish knowledge base of wait types: what each means, when it is normal, typical causes and what to do. The wait
/// rules (SQLI-WAIT-*) use the same texts, so a finding and the wait type page say the same.
/// </summary>
public static class WaitKnowledge
{
public static readonly WaitExplanation Locks = new(
"LCK_M_*",
"Venter på en lås",
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
+ "læsninger, der blokeres af skrivninger.",
"Korte låseventetider (millisekunder) er normalt, når mange arbejder på de samme data. Det bliver et problem, når enkelte ventetider varer sekunder, eller når mange sessioner venter på den samme.",
"Lange transaktioner, en transaktion der står åben (fx en bruger midt i en dialog), opdateringer uden passende index, "
+ "eskalering til tabellås ved store opdateringer, og læsere der venter på skrivere (READ COMMITTED uden row versioning). "
+ "Bogstaverne efter LCK_M_ angiver låsetypen: S (læs), U (opdatér), X (skriv), IS/IX/IU (hensigt), SCH_S/SCH_M (skema), RS/RIn/RX (range, ved SERIALIZABLE).",
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
"AX bruger optimistisk samtidighed for de fleste tabeller, men nummerserier (NUMBERSEQUENCETABLE), lagerposter og "
+ "batchjob, der opdaterer mange rækker i én transaktion, giver typisk LCK_M_U/LCK_M_X. AX 2009/2012 kører som "
+ "standard med READ_COMMITTED_SNAPSHOT slået til; er det slået fra, giver læsninger LCK_M_S.");
public static readonly WaitExplanation BufferIo = new(
"PAGEIOLATCH_*",
"Venter på at læse data fra disk",
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
"Lidt PAGEIOLATCH_SH er normalt, især efter genstart og når der læses data, der sjældent bruges (rapporter, natlige job).",
"Forespørgsler der scanner store tabeller, for lidt hukommelse til de data, der bruges ofte (lav Page Life Expectancy), "
+ "langsom eller overbelastet storage. _SH er læsning, _EX/_UP er sider, der skal ændres.",
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
+ "Tjek derefter, om max server memory er sat fornuftigt.",
"I AX skyldes det ofte manglende indexes på kundetilpassede tabeller eller forespørgsler uden DATAAREAID (og PARTITION fra AX 2012) først i indexet.");
public static readonly WaitExplanation Parallelism = new(
"CXPACKET, CXCONSUMER, CXSYNC_*",
"Parallelle forespørgsler venter på hinanden",
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
"CXCONSUMER (fra 2016 SP2/2017 CU3) er forbrugersiden og næsten altid harmløs. CXPACKET og CXSYNC_PORT/CXSYNC_CONSUMER i moderat mængde er normalt på en server med rapporter og store forespørgsler.",
"Lav cost threshold for parallelism (standard 5), MAXDOP 0 på en server med mange kerner, manglende indexes så "
+ "forespørgsler scanner, og forældede statistikker der giver skæv fordeling mellem tråde.",
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
"Microsoft anbefaler MAXDOP 1 for Dynamics AX 2009/2012 (og AX 4.0), fordi AX' mange korte OLTP-forespørgsler "
+ "ikke har gavn af parallelisme. Ses CXPACKET på en AX-database, er MAXDOP sandsynligvis ikke sat til 1.");
public static readonly WaitExplanation PageLatch = new(
"PAGELATCH_*",
"Konkurrence om sider i hukommelsen",
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
"Korte PAGELATCH-ventetider er normalt under travl indsættelse.",
"Mange samtidige oprettelser af temp-tabeller med for få tempdb-datafiler (PFS/GAM/SGAM-sider), og mange "
+ "samtidige indsættelser i den sidste side af et index med stigende nøgle (identity, dato, RECID).",
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
"AX bruger mange temp-tabeller (TempDB-tabeller i AX 2012): tempdb bør have flere lige store datafiler.");
public static readonly WaitExplanation ThreadPool = new(
"THREADPOOL",
"SQL Server løber tør for worker threads",
"THREADPOOL-ventetider betyder, at nye opgaver ikke kunne få en worker thread. Imens hænger nye forbindelser og "
+ "forespørgsler, og serveren virker død udefra. Årsagen er næsten altid massiv blocking (mange sessioner, der "
+ "venter på den samme lås) eller mange samtidige parallelle forespørgsler.",
"Aldrig. Selv korte THREADPOOL-ventetider betyder, at serveren i et øjeblik ikke kunne tage imod mere arbejde.",
"En head blocker, som hundredvis af sessioner venter på, mange parallelle forespørgsler på én gang, eller for få CPU'er til belastningen.",
"Find head blocker (se blocking-fund) og løs den. Brug Dedicated Admin Connection (ADMIN:), hvis almindelige "
+ "forbindelser hænger. Hæv ikke 'max worker threads' som løsning; det flytter kun problemet.");
public static readonly WaitExplanation WriteLog = new(
"WRITELOG",
"Commits venter på at skrive til transaktionsloggen",
"Hver commit venter, til loggen er skrevet til disk. Gennemsnitligt over 5 ms pr. WRITELOG-wait gør alle "
+ "skrivende transaktioner langsommere og begrænser, hvor mange commits serveren kan nå pr. sekund.",
"WRITELOG findes på alle servere, der skriver. Det er normalt, så længe den gennemsnitlige ventetid pr. wait er under 1–2 ms (lokal SSD) eller 5 ms.",
"Langsom storage under logfilen, logfilen på samme disk som datafilerne, mange små transaktioner (commit pr. række i en løkke), eller synkron replikering.",
"Tjek skrivelatens på logfilerne (se I/O-fund). Placér loggen på hurtig storage med lav latens, adskilt fra datafiler. "
+ "Undgå mange små transaktioner i løkker; saml dem i batches. Overvej delayed durability kun, hvis datatab ved nedbrud er acceptabelt.",
"AX-batchjob, der poster mange enkelttransaktioner (fx lagerposteringer), giver mange commits; hurtig log-storage betyder meget.");
public static readonly WaitExplanation SchedulerYield = new(
"SOS_SCHEDULER_YIELD",
"Forespørgsler har brugt deres tid på CPU'en og venter på at komme til igen",
"En task har brugt sit tidskvantum (4 ms) på CPU'en og har frivilligt givet plads til andre. Den venter så på at komme til igen. "
+ "Meget af det betyder, at CPU'en er den begrænsende ressource.",
"Altid til stede på en server, der arbejder. Det er kun et problem, når CPU-forbruget samtidig er højt, eller signal wait er over ca. 25 %.",
"CPU-tunge forespørgsler: scans af data i hukommelsen, manglende indexes, implicitte konverteringer, skalare funktioner, for mange kompileringer.",
"Find forespørgslerne med højest CPU-forbrug i Query Store eller sys.dm_exec_query_stats og optimér dem "
+ "(manglende indexes, implicitte konverteringer, skalare funktioner). Tjek MAXDOP og cost threshold for parallelism. "
+ "Mere CPU er først svaret, når forespørgslerne er effektive.");
private static readonly WaitExplanation[] Families =
[
Locks, BufferIo, Parallelism, PageLatch, ThreadPool, WriteLog, SchedulerYield,
new("LOGBUFFER", "Venter på plads i logbufferen",
"En session venter på plads i logbufferen i hukommelsen, fordi den ikke bliver skrevet til disk hurtigt nok.",
"Sjælden og kortvarig på en sund server.",
"Langsom log-storage, eller meget store mængder log på kort tid (indexvedligehold, store opdateringer).",
"Behandles som WRITELOG: tjek skrivelatens på logfilerne og storage under dem."),
new("ASYNC_NETWORK_IO", "SQL Server venter på, at klienten henter data",
"SQL Server har resultatet klar, men klienten (applikationen) læser det ikke hurtigt nok. Ventetiden er i klienten eller netværket, ikke i SQL Server.",
"Normalt ved store resultatsæt over netværk og ved klienter, der behandler række for række.",
"Applikationer, der henter mange rækker og behandler dem én ad gangen, rapporter der henter hele tabeller, et langsomt netværk eller en overbelastet applikationsserver.",
"Find hvilken applikation og forespørgsel det er (forespørgslerne nedenfor). Hent kun de rækker og kolonner, der bruges, og behandl resultatet hurtigt. "
+ "Tjek CPU og netværk på applikationsserveren. At give SQL Server mere hukommelse eller CPU hjælper ikke.",
"I AX er det typisk AOS'en: en AOS-server med høj CPU, eller X++-kode der løber igennem mange poster med while select og laver arbejde pr. post."),
new("RESOURCE_SEMAPHORE", "Forespørgsler venter på hukommelse til at køre",
"En forespørgsel skal bruge hukommelse til sortering eller hash (memory grant) og venter, fordi der ikke er nok ledig. Den er ikke begyndt at køre endnu.",
"Aldrig i længere tid. Ses det, er Memory Grants Pending større end 0.",
"Forespørgsler med store memory grants (store sorteringer og hash joins, ofte pga. forkerte estimater), for lidt hukommelse, eller mange samtidige rapporter.",
"Find forespørgslerne med de største memory grants (sys.dm_exec_query_memory_grants), opdatér statistikker og tilføj indexes, så de ikke skal sortere så meget. "
+ "Tjek max server memory. Resource Governor kan begrænse grants for rapporter."),
new("RESOURCE_SEMAPHORE_QUERY_COMPILE", "Kompileringer venter på hukommelse",
"For mange store forespørgsler kompileres samtidig, og SQL Server begrænser hvor mange der må bruge meget hukommelse til kompilering.",
"Sjælden. Ses typisk ved mange ad hoc-forespørgsler, der ikke genbruger planer.",
"Mange forskellige ad hoc-forespørgsler (literaler i stedet for parametre), meget komplekse forespørgsler, eller plan cache, der tømmes ofte.",
"Tjek SQL Compilations/sec mod Batch Requests/sec. Brug parametre, overvej 'optimize for ad hoc workloads' og forced parameterization for en database med mange ens ad hoc-forespørgsler.",
"AX sender forespørgsler med literaler, når 'Use literals' er valgt på forespørgslen eller tabellen; det giver mange kompileringer."),
new("CMEMTHREAD", "Konkurrence om en delt hukommelsesstruktur",
"Tråde venter på adgang til et hukommelsesobjekt, der deles af mange. Ses på servere med mange kerner og mange samtidige kompileringer.",
"Kortvarigt og i små mængder.",
"Mange ad hoc-forespørgsler, der skal i plan cache samtidig, særligt på NUMA-servere med mange kerner.",
"Reducér antallet af kompileringer (parametre, 'optimize for ad hoc workloads'). Trace flag 8048 (2014 og ældre) partitionerer objekterne pr. CPU; fra 2016 sker det automatisk."),
new("IO_COMPLETION", "Venter på I/O, der ikke er læsning af datasider",
"Venter på anden I/O end datasider: sort og hash, der skriver til tempdb, læsning af logfilen ved fx rollback, og ved oprettelse af filer.",
"Ses ved store sorteringer, backup/restore og ved databasestart.",
"Store sorteringer og hash joins, der spildes til tempdb, langsom tempdb-storage, langsom logfil.",
"Find forespørgsler med spill til tempdb (Sort/Hash Warnings i planen), og tjek latens på tempdb og logfiler."),
new("ASYNC_IO_COMPLETION", "Venter på asynkron I/O, typisk backup eller filvækst",
"Venter på asynkron I/O: backup-læsning, oprettelse eller udvidelse af filer uden Instant File Initialization, og databasesnapshots.",
"Normalt under backup, restore og filvækst.",
"Backup, filvækst (særligt logfiler, som altid skal nulstilles), manglende Instant File Initialization.",
"Hvis det falder sammen med backup, er det forventet. Ellers: slå Instant File Initialization til og sæt autogrowth til faste, passende størrelser."),
new("BACKUPIO, BACKUPBUFFER", "Backup venter på disk eller bånd",
"Backup eller restore venter på at læse eller skrive data. BACKUPIO er ventetid på backupmediet, BACKUPBUFFER på buffere.",
"Normalt under backup og restore.",
"Langsomt backupmål (netværksdrev, bånd), backup af mange databaser på én gang, eller backup-software der læser langsomt.",
"Sammenhold med backuptiderne. Backup til hurtigere mål, kompression, flere filer (striping) eller spredning af backups over tid."),
new("HADR_SYNC_COMMIT", "Commits venter på den synkrone replika",
"Med synkron Availability Group venter hver commit på, at den sekundære replika har skrevet loggen.",
"Altid lidt til stede med synkron commit; et par millisekunder pr. commit er normalt.",
"Netværkslatens til den sekundære replika, langsom log-storage på den sekundære, eller en sekundær med redo-kø.",
"Tjek netværket og log-storage på den sekundære replika. Overvej asynkron commit for replikaer, der ikke skal bruges til automatisk failover."),
new("OLEDB", "Venter på en linked server eller en DMV-funktion",
"SQL Server venter på svar fra en OLE DB-udbyder: oftest en linked server-forespørgsel, men også visse DMV-funktioner og DBCC.",
"Normalt ved brug af linked servers og under DBCC CHECKDB.",
"Langsomme forespørgsler mod linked servers, eller forespørgsler, der henter hele fjerntabeller over netværket.",
"Find forespørgslerne nedenfor. Lad den fjerne server filtrere (OPENQUERY), og hent kun de rækker, der skal bruges."),
new("PREEMPTIVE_*", "SQL Server venter på Windows eller en ekstern komponent",
"Tråden har overladt kontrollen til Windows (filsystem, netværk, sikkerhed, COM) eller en ekstern komponent, og SQL Server kan ikke se ind i den ventetid.",
"Mange PREEMPTIVE_* er normale baggrundsopgaver. Navnet efter PREEMPTIVE_OS_ siger hvilket kald, fx AUTHENTICATIONOPS (AD-login), WRITEFILEGATHER (filvækst).",
"Langsom domænecontroller ved Windows-login, filvækst uden Instant File Initialization, linked servers, CLR og extended stored procedures.",
"Se navnet: PREEMPTIVE_OS_AUTHENTICATIONOPS → tjek forbindelsen til domænecontrolleren; PREEMPTIVE_OS_WRITEFILEGATHER → slå Instant File Initialization til."),
new("LATCH_*", "Konkurrence om interne strukturer i SQL Server",
"Latches beskytter interne strukturer i hukommelsen (ikke datasider). LATCH_EX og LATCH_SH er ventetid på dem.",
"Små mængder er normale.",
"Typisk ACCESS_METHODS_DATASET_PARENT (parallelle scans) eller FGCB_ADD_REMOVE (filvækst). Hvilken latch det er, står i sys.dm_os_latch_stats.",
"Se sys.dm_os_latch_stats for hvilken latch det er. Parallelle scans: se CXPACKET. Filvækst: sæt passende autogrowth og Instant File Initialization."),
new("MSQL_XP", "Venter på en extended stored procedure",
"SQL Server venter på, at en extended stored procedure (xp_…) bliver færdig, fx xp_cmdshell eller tredjeparts backupværktøjer.",
"Normalt for job, der bruger xp_cmdshell, og for visse backupværktøjer.",
"Lange kommandoer kørt med xp_cmdshell, tredjepartsudvidelser, der hænger.",
"Find hvilken procedure det er (forespørgslerne nedenfor) og om ventetiden svarer til, at den gør noget langvarigt."),
new("WAIT_ON_SYNC_STATISTICS_REFRESH", "Forespørgsler venter på, at statistik bliver opdateret",
"En forespørgsel skal bruge en statistik, der er forældet, og venter på, at SQL Server opdaterer den, før den kan kompilere.",
"Lidt er normalt efter store dataændringer.",
"Store tabeller, hvor automatisk statistikopdatering sker midt i travl drift.",
"Slå AUTO_UPDATE_STATISTICS_ASYNC til for databasen, så forespørgsler bruger den gamle statistik, mens den nye bygges, og opdatér statistik i et vedligeholdelsesvindue.",
"For AX-databaser er AUTO_UPDATE_STATISTICS_ASYNC en almindelig anbefaling sammen med natlig statistikopdatering."),
new("SOS_WORK_DISPATCHER", "Ledig worker-tråd venter på arbejde",
"En ledig tråd venter på at få tildelt arbejde. Ikke et problem.",
"Altid.",
"–",
"Intet at gøre."),
new("WAITFOR", "Forespørgslen venter med vilje (WAITFOR)",
"Koden har selv bedt om at vente med WAITFOR DELAY eller WAITFOR RECEIVE.",
"Altid, når koden gør det med vilje.",
"Kode, job eller Service Broker, der venter med vilje.",
"Intet at gøre i SQL Server; se på koden, hvis ventetiden ikke er forventet."),
];
/// <summary>The explanation for a wait type: its own, its family's (LCK_M_X → LCK_M_*), or a generic text.</summary>
public static WaitExplanation Lookup(string waitType)
{
ArgumentNullException.ThrowIfNull(waitType);
var w = waitType.Trim().ToUpperInvariant();
foreach (var entry in Families)
{
foreach (var pattern in entry.Family.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (pattern.EndsWith('*') ? w.StartsWith(pattern[..^1], StringComparison.Ordinal) : w == pattern)
{
return entry;
}
}
}
return w switch
{
"EXCHANGE" or "CXSYNC_PORT" or "CXSYNC_CONSUMER" => Parallelism,
"NETWORKIO" => Lookup("ASYNC_NETWORK_IO"),
_ when WaitCatalog.IsBenign(w) => new(w, "Baggrundsventetid uden betydning",
$"{w} er en ventetid, som SQL Server's egne baggrundsopgaver har med vilje. Den siger intet om belastningen.",
"Altid.", "–", "Intet at gøre."),
_ => Generic(w),
};
}
private static WaitExplanation Generic(string waitType) => new(
waitType,
"Ventetype uden dansk forklaring endnu",
$"SQLInsight Pro har endnu ingen forklaring på {waitType}. Kategorien er {Category(waitType)}.",
"Vurdér ud fra hvor stor en andel af ventetiden den udgør, og om den er steget i forhold til normalen.",
"Se Microsofts beskrivelse af ventetypen (linket nedenfor) og docs/analyse.md.",
"Se på de forespørgsler, der venter på den (nedenfor), og om ventetiden følger et bestemt tidspunkt eller job.",
Known: false);
private static string Category(string waitType) => WaitCatalog.Categorize(waitType) switch
{
WaitCategory.Cpu => "CPU",
WaitCategory.WorkerThreads => "worker threads",
WaitCategory.Parallelism => "parallelisme",
WaitCategory.Locks => "låse",
WaitCategory.BufferIo => "læsning fra disk",
WaitCategory.PageLatch => "sider i hukommelsen",
WaitCategory.Latch => "interne latches",
WaitCategory.TransactionLog => "transaktionsloggen",
WaitCategory.Memory => "hukommelse",
WaitCategory.Network => "netværk og klient",
WaitCategory.OtherIo => "anden I/O",
WaitCategory.AvailabilityGroups => "Availability Groups",
WaitCategory.Preemptive => "Windows/eksterne kald",
_ => "anden",
};
}

View file

@ -1,67 +0,0 @@
namespace SQLInsight.Core.Data;
/// <summary>
/// One user request that was waiting when a sample was taken (sys.dm_exec_requests). <see cref="QueryKey"/> is the same
/// key as top SQL uses (query_hash, or on SQL Server 2005 the batch and offsets), so the statement's text and use can
/// be joined. <see cref="Text"/> is customer data: kept locally only.
/// </summary>
public sealed record WaitingRequest(int SessionId, string WaitType, long WaitTimeMs, string? DatabaseName, string? QueryKey, string? Text, int BlockingSessionId);
/// <summary>The waiting requests of one sample, summed by wait type, database and statement.</summary>
/// <param name="Sessions">How many requests were waiting on it at the moment of the sample.</param>
/// <param name="WaitTimeMs">Their wait time so far (sum).</param>
/// <param name="BlockedSessions">How many of them were blocked by another session.</param>
public sealed record WaitSampleRow(string WaitType, string? DatabaseName, string? QueryKey, int Sessions, long WaitTimeMs, long MaxWaitTimeMs, int BlockedSessions);
/// <summary>Active session sampling: what user sessions were waiting on, sampled every 15 seconds.</summary>
public sealed record WaitSampleSnapshot
{
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyList<WaitSampleRow> Rows { get; init; }
/// <summary>Statement texts by query key (customer data; only stored locally, never as a JSON snapshot).</summary>
public IReadOnlyList<SampledStatement> Statements { get; init; } = [];
}
public sealed record SampledStatement(string QueryKey, string? DatabaseName, string? Text);
public static class WaitSampling
{
/// <summary>How often a sample is taken.</summary>
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(15);
/// <summary>Program name prefix of the agent's own connections, which are never sampled.</summary>
public const string AgentProgramPrefix = "SQLInsight Pro";
/// <summary>
/// Sums one sample's waiting requests by wait type, database and statement. Background waits that mean nothing
/// (see <see cref="WaitCatalog.IsBenign"/>) are left out.
/// </summary>
public static WaitSampleSnapshot Aggregate(IEnumerable<WaitingRequest> requests, DateTimeOffset at)
{
ArgumentNullException.ThrowIfNull(requests);
var waiting = requests.Where(r => !string.IsNullOrEmpty(r.WaitType) && !WaitCatalog.IsBenign(r.WaitType)).ToList();
var rows = waiting
.GroupBy(r => (WaitType: r.WaitType.ToUpperInvariant(), r.DatabaseName, r.QueryKey))
.Select(g => new WaitSampleRow(g.Key.WaitType, g.Key.DatabaseName, g.Key.QueryKey, g.Count(),
g.Sum(r => Math.Max(0, r.WaitTimeMs)), g.Max(r => Math.Max(0, r.WaitTimeMs)), g.Count(r => r.BlockingSessionId > 0)))
.OrderByDescending(r => r.Sessions).ThenBy(r => r.WaitType, StringComparer.Ordinal)
.ToList();
var statements = waiting
.Where(r => r.QueryKey is not null)
.GroupBy(r => r.QueryKey!, StringComparer.Ordinal)
.Select(g => new SampledStatement(g.Key, g.Select(r => r.DatabaseName).FirstOrDefault(d => d is not null),
Cut(g.Select(r => r.Text).FirstOrDefault(t => t is not null))))
.ToList();
return new WaitSampleSnapshot { CapturedAt = at, Rows = rows, Statements = statements };
}
private static string? Cut(string? text) => text is { Length: > QueryStat.MaxTextLength } ? text[..QueryStat.MaxTextLength] : text;
/// <summary>Lock waits, where the blocking chain explains the wait.</summary>
public static bool IsLockWait(string waitType)
{
ArgumentNullException.ThrowIfNull(waitType);
return waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase);
}
}

View file

@ -64,7 +64,4 @@ public enum Capability
/// <summary><c>sys.dm_exec_text_query_plan</c> (cached plans as text, per statement). SQL Server 2005 SP2+.</summary>
TextQueryPlan,
/// <summary><c>sys.dm_os_volume_stats</c> and <c>sys.dm_server_services</c>. SQL Server 2008 R2 SP1+.</summary>
VolumeStats,
}

View file

@ -13,7 +13,6 @@ public sealed class InstanceCapabilities
[Capability.DefaultTrace] = new(9, 0, 0, 0),
[Capability.ViewServerStatePermission] = new(9, 0, 0, 0),
[Capability.TextQueryPlan] = new(9, 0, 3042, 0), // 2005 SP2
[Capability.VolumeStats] = new(10, 50, 2500, 0), // 2008 R2 SP1
[Capability.ExtendedEvents] = new(10, 0, 0, 0),
[Capability.SystemHealthSession] = new(10, 0, 0, 0),
[Capability.SystemHealthFileTarget] = new(11, 0, 0, 0),
@ -86,14 +85,6 @@ public sealed class InstanceCapabilities
public static bool IsInherent(Capability capability) =>
MinimumVersion.ContainsKey(capability) || capability is Capability.QueryStatsMemoryColumns or Capability.SelfManagedInfrastructure;
/// <summary>
/// Capabilities that are a way of measuring, not a feature: without them the topic still applies on the version and
/// can be checked by hand (see IRule.ManualCheck), unlike Query Store or availability groups, which simply are not there.
/// </summary>
public static bool IsMeasurement(Capability capability) => capability is Capability.LastGoodCheckDbTime or Capability.LogInfoDmv
or Capability.QueryStatsMemoryColumns or Capability.SystemHealthSession or Capability.SystemHealthFileTarget or Capability.ExtendedEvents
or Capability.SessionWaitStats or Capability.LastActualPlan or Capability.TextQueryPlan or Capability.VolumeStats;
/// <summary>
/// "kræver Query Store (fra SQL Server 2016)" for the missing capabilities, for not-applicable reasons.
/// </summary>

View file

@ -11,7 +11,6 @@ namespace SQLInsight.Hub.Api;
/// Support cases for paying customers, through their agent:
/// POST /api/v1/cases creates a case (idempotent on ClientRequestId) and returns its number;
/// GET /api/v1/cases lists the customer's cases, also those SEOSoft created, so SQLInsight Web can show their status.
/// POST /api/v1/cases/{reference}/close closes one of the customer's own cases (idempotent).
/// </summary>
public static class CasesEndpoint
{
@ -21,7 +20,6 @@ public static class CasesEndpoint
{
app.MapPost("/api/v1/cases", CreateAsync).DisableAntiforgery().AllowAnonymous();
app.MapGet("/api/v1/cases", ListAsync).AllowAnonymous();
app.MapPost("/api/v1/cases/{reference}/close", CloseAsync).DisableAntiforgery().AllowAnonymous();
}
internal static async Task<IResult> CreateAsync(
@ -140,57 +138,5 @@ public static class CasesEndpoint
return Results.Ok(cases.Select(SupportCaseStore.ToDto).ToList());
}
internal static async Task<IResult> CloseAsync(
string reference, HttpRequest request, HubDbContext db, IOptions<HubServerOptions> options, TimeProvider time, CancellationToken cancellationToken)
{
var (license, rejected) = await FindingsEndpoint.AuthorizeAsync(request, db, options.Value, time, cancellationToken).ConfigureAwait(false);
if (license is null)
{
return rejected!;
}
if (!license.IncludesPrioritySupport)
{
return FindingsEndpoint.Problem(403, "Licensen giver ikke support", "Supportsager kræver Professional eller Enterprise.");
}
SupportCaseCloseRequest body;
try
{
body = request.ContentLength is null or 0
? new SupportCaseCloseRequest(null, null)
: await JsonSerializer.DeserializeAsync<SupportCaseCloseRequest>(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false)
?? new SupportCaseCloseRequest(null, null);
}
catch (JsonException ex)
{
return FindingsEndpoint.Problem(400, "Ugyldig JSON", ex.Message);
}
if (body.Validate() is { } invalid)
{
return FindingsEndpoint.Problem(422, "Ugyldig lukning", invalid);
}
// Only the license's own customer's cases: anyone else's is simply not found.
var supportCase = await db.SupportCases
.SingleOrDefaultAsync(c => c.Customer!.Name == license.Customer && c.Reference == reference, cancellationToken).ConfigureAwait(false);
if (supportCase is null)
{
return FindingsEndpoint.Problem(404, "Ukendt sag", $"Sagen {reference} findes ikke.");
}
if (supportCase.IsOpen)
{
supportCase.IsOpen = false;
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
supportCase.ClosedBy = "Kunde: " + (string.IsNullOrWhiteSpace(body.RequestedBy) ? "ukendt" : body.RequestedBy.Trim());
supportCase.ClosedComment = Trimmed(body.Comment);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return Results.Ok(SupportCaseStore.ToDto(supportCase));
}
private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View file

@ -104,12 +104,6 @@ public sealed class SupportCase
public bool IsOpen { get; set; } = true;
public DateTime CreatedAt { get; set; }
public DateTime? ClosedAt { get; set; }
/// <summary>Who closed it: "Kunde: DOMÆNE\bruger" (from SQLInsight Web or its API) or "SEOSoft: navn" (the portal).</summary>
public string? ClosedBy { get; set; }
/// <summary>The comment given when the case was closed.</summary>
public string? ClosedComment { get; set; }
public List<HubFinding> Findings { get; } = [];
/// <summary>Who created it: <see cref="Contracts.SupportCaseSources.Staff"/> or <see cref="Contracts.SupportCaseSources.Customer"/>.</summary>

View file

@ -1,594 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using SQLInsight.Hub.Data;
#nullable disable
namespace SQLInsight.Hub.Data.Migrations
{
[DbContext(typeof(HubDbContext))]
[Migration("20261002072808_CaseClosedBy")]
partial class CaseClosedBy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.HasKey("Id")
.HasName("pk_customers");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_customers_name");
b.ToTable("customers", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("evidence_json");
b.Property<string>("Explanation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("explanation");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("Recommendation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("recommendation");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("RuleId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<int>("Severity")
.HasColumnType("integer")
.HasColumnName("severity");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<int?>("SupportCaseId")
.HasColumnType("integer")
.HasColumnName("support_case_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_findings");
b.HasIndex("SupportCaseId")
.HasDatabaseName("ix_findings_support_case_id");
b.HasIndex("Severity", "ResolvedAt")
.HasDatabaseName("ix_findings_severity_resolved_at");
b.HasIndex("InstanceId", "RuleId", "Subject")
.IsUnique()
.HasDatabaseName("ix_findings_instance_id_rule_id_subject")
.HasFilter("resolved_at IS NULL");
b.ToTable("findings", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AgentVersion")
.HasColumnType("text")
.HasColumnName("agent_version");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Edition")
.HasColumnType("text")
.HasColumnName("edition");
b.Property<string>("InstanceKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("instance_key");
b.Property<DateTime?>("LastCollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_collected_at");
b.Property<DateTime?>("LastSubmissionAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_submission_at");
b.Property<string>("ProductLevel")
.HasColumnType("text")
.HasColumnName("product_level");
b.Property<string>("ProductUpdateLevel")
.HasColumnType("text")
.HasColumnName("product_update_level");
b.Property<string>("ProductVersion")
.HasColumnType("text")
.HasColumnName("product_version");
b.Property<string>("Release")
.HasColumnType("text")
.HasColumnName("release");
b.Property<string>("ServerName")
.HasColumnType("text")
.HasColumnName("server_name");
b.HasKey("Id")
.HasName("pk_instances");
b.HasIndex("CustomerId", "InstanceKey")
.IsUnique()
.HasDatabaseName("ix_instances_customer_id_instance_key");
b.ToTable("instances", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("ChangedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("changed_at");
b.Property<string>("FromVersion")
.IsRequired()
.HasColumnType("text")
.HasColumnName("from_version");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("ToLevel")
.HasColumnType("text")
.HasColumnName("to_level");
b.Property<string>("ToVersion")
.IsRequired()
.HasColumnType("text")
.HasColumnName("to_version");
b.HasKey("Id")
.HasName("pk_version_changes");
b.HasIndex("InstanceId", "ChangedAt")
.HasDatabaseName("ix_version_changes_instance_id_changed_at");
b.ToTable("version_changes", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
{
b.Property<string>("LicenseId")
.HasColumnType("text")
.HasColumnName("license_id");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTime>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("issued_at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<int>("MaxInstances")
.HasColumnType("integer")
.HasColumnName("max_instances");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("RevokedReason")
.HasColumnType("text")
.HasColumnName("revoked_reason");
b.HasKey("LicenseId")
.HasName("pk_licenses");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_licenses_customer_id");
b.ToTable("licenses", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.RuleState", b =>
{
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<string>("RuleId")
.HasColumnType("text")
.HasColumnName("rule_id");
b.Property<string>("Outcome")
.IsRequired()
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("InstanceId", "RuleId")
.HasName("pk_rule_states");
b.ToTable("rule_states", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.StaffUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_login_at");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password_hash");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_staff_users");
b.HasIndex("UserName")
.IsUnique()
.HasDatabaseName("ix_staff_users_user_name");
b.ToTable("staff_users", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.Submission", b =>
{
b.Property<Guid>("SubmissionId")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("submission_id");
b.Property<bool>("Applied")
.HasColumnType("boolean")
.HasColumnName("applied");
b.Property<DateTime>("CollectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("collected_at");
b.Property<int>("FindingCount")
.HasColumnType("integer")
.HasColumnName("finding_count");
b.Property<int>("InstanceId")
.HasColumnType("integer")
.HasColumnName("instance_id");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.HasKey("SubmissionId")
.HasName("pk_submissions");
b.HasIndex("ReceivedAt")
.HasDatabaseName("ix_submissions_received_at");
b.ToTable("submissions", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("ClientRequestId")
.HasColumnType("uuid")
.HasColumnName("client_request_id");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<string>("ClosedBy")
.HasColumnType("text")
.HasColumnName("closed_by");
b.Property<string>("ClosedComment")
.HasColumnType("text")
.HasColumnName("closed_comment");
b.Property<string>("ContactEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("contact_email");
b.Property<string>("ContactName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("contact_name");
b.Property<string>("ContactPhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("contact_phone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CustomerId")
.HasColumnType("integer")
.HasColumnName("customer_id");
b.Property<string>("Description")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)")
.HasColumnName("description");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Reference")
.IsRequired()
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("RequestedBy")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("requested_by");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasDefaultValue("Staff")
.HasColumnName("source");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_support_cases");
b.HasIndex("CreatedAt")
.HasDatabaseName("ix_support_cases_created_at");
b.HasIndex("CustomerId", "ClientRequestId")
.IsUnique()
.HasDatabaseName("ix_support_cases_customer_id_client_request_id")
.HasFilter("client_request_id IS NOT NULL");
b.HasIndex("CustomerId", "Reference")
.IsUnique()
.HasDatabaseName("ix_support_cases_customer_id_reference");
b.ToTable("support_cases", (string)null);
});
modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
{
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
.WithMany("Findings")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_findings_instances_instance_id");
b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase")
.WithMany("Findings")
.HasForeignKey("SupportCaseId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_findings_support_cases_support_case_id");
b.Navigation("Instance");
b.Navigation("SupportCase");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("Instances")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_instances_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.InstanceVersionChange", b =>
{
b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
.WithMany()
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_version_changes_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("Licenses")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_licenses_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
.WithMany("SupportCases")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_support_cases_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
{
b.Navigation("Instances");
b.Navigation("Licenses");
b.Navigation("SupportCases");
});
modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
{
b.Navigation("Findings");
});
modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
{
b.Navigation("Findings");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,38 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SQLInsight.Hub.Data.Migrations
{
/// <inheritdoc />
public partial class CaseClosedBy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "closed_by",
table: "support_cases",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "closed_comment",
table: "support_cases",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "closed_by",
table: "support_cases");
migrationBuilder.DropColumn(
name: "closed_comment",
table: "support_cases");
}
}
}

View file

@ -414,14 +414,6 @@ namespace SQLInsight.Hub.Data.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("closed_at");
b.Property<string>("ClosedBy")
.HasColumnType("text")
.HasColumnName("closed_by");
b.Property<string>("ClosedComment")
.HasColumnType("text")
.HasColumnName("closed_comment");
b.Property<string>("ContactEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")

View file

@ -70,7 +70,7 @@
else
{
<table>
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th></tr></thead>
<thead><tr><th>Sag</th><th>Titel</th><th>Kilde</th><th>Status</th><th>Oprettet</th><th>Lukket</th></tr></thead>
<tbody>
@foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt))
{
@ -81,7 +81,6 @@
<td>@(c.IsOpen ? "Åben" : "Lukket")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)</td>
<td>@c.ClosedBy@(c.ClosedComment is { } comment ? $": {comment}" : "")</td>
</tr>
}
</tbody>

View file

@ -82,7 +82,7 @@
else
{
<table>
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Lukket af</th><th>Løsningstid</th></tr></thead>
<thead><tr><th>Sag</th><th>Kunde</th><th>Titel</th><th>Kilde</th><th>Oprettet</th><th>Lukket</th><th>Løsningstid</th></tr></thead>
<tbody>
@foreach (var c in _report.Cases)
{
@ -93,7 +93,6 @@
<td>@(c.Source == SQLInsight.Contracts.SupportCaseSources.Customer ? "Kunde" : "SEOSoft")</td>
<td class="nowrap">@c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td class="nowrap">@c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td title="@c.ClosedBy">@c.ClosedByWhom</td>
<td class="nowrap">@(c.ClosedAt is null ? "åben" : SupportReport.Duration(c.ResolutionTime))</td>
</tr>
}

View file

@ -41,7 +41,7 @@ public static class PortalEndpoints
await db.SaveChangesAsync(ct);
return Results.Redirect($"/portal/fund/{id}");
});
portal.MapPost("/cases/{id:int}/close", async (int id, HttpContext http, HubDbContext db, TimeProvider time, CancellationToken ct) =>
portal.MapPost("/cases/{id:int}/close", async (int id, HubDbContext db, TimeProvider time, CancellationToken ct) =>
{
var supportCase = await db.SupportCases.FindAsync([id], ct);
if (supportCase is null)
@ -49,13 +49,8 @@ public static class PortalEndpoints
return Results.NotFound();
}
if (supportCase.IsOpen)
{
supportCase.IsOpen = false;
supportCase.ClosedAt = time.GetUtcNow().UtcDateTime;
supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt");
}
await db.SaveChangesAsync(ct);
return Results.Redirect("/portal/sager");
});

View file

@ -110,12 +110,11 @@ public sealed class PortalQueries(HubDbContext db, TimeProvider time)
var cases = await db.SupportCases
.Where(c => customerId == null || c.CustomerId == customerId)
.Where(c => c.IsOpen || (c.CreatedAt >= from && c.CreatedAt < to) || (c.ClosedAt >= from && c.ClosedAt < to))
.Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt, c.ClosedBy })
.Select(c => new { c.Id, c.CustomerId, Customer = c.Customer!.Name, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen, c.ClosedAt })
.ToListAsync(ct).ConfigureAwait(false);
// A closed case without a closing time (closed before it was recorded) counts as closed when it was created.
return Support.SupportReport.Build(
cases.Select(c => new Support.SupportReportCase(c.Id, c.CustomerId, c.Customer, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen ? null : c.ClosedAt ?? c.CreatedAt,
c.IsOpen ? null : c.ClosedBy)),
cases.Select(c => new Support.SupportReportCase(c.Id, c.CustomerId, c.Customer, c.Reference, c.Title, c.Source, c.CreatedAt, c.IsOpen ? null : c.ClosedAt ?? c.CreatedAt)),
from, to, Now);
}

View file

@ -67,8 +67,7 @@ public static class SupportCaseStore
public static SupportCaseDto ToDto(SupportCase c)
{
ArgumentNullException.ThrowIfNull(c);
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId,
c.ClosedBy, c.ClosedComment);
return new SupportCaseDto(c.Reference, c.Title, c.IsOpen, Utc(c.CreatedAt), c.ClosedAt is { } closed ? Utc(closed) : null, c.Source, c.ClientRequestId);
}
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));

View file

@ -5,14 +5,9 @@ using SQLInsight.Contracts;
namespace SQLInsight.Hub.Support;
/// <summary>A support case as the report needs it.</summary>
/// <param name="ClosedBy">"Kunde: …" or "SEOSoft: …"; null for open cases and cases closed before it was recorded.</param>
public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt,
string? ClosedBy = null)
public sealed record SupportReportCase(int Id, int CustomerId, string Customer, string Reference, string Title, string Source, DateTime CreatedAt, DateTime? ClosedAt)
{
public TimeSpan? ResolutionTime => ClosedAt - CreatedAt;
/// <summary>"Kunde" when the customer closed it, "SEOSoft" when staff did, empty while open.</summary>
public string ClosedByWhom => ClosedAt is null ? string.Empty : ClosedBy?.StartsWith("Kunde", StringComparison.Ordinal) == true ? "Kunde" : "SEOSoft";
}
/// <summary>Key figures for one customer (or, for the total row, all customers).</summary>
@ -132,7 +127,7 @@ public static class SupportReport
{
ArgumentNullException.ThrowIfNull(report);
var csv = new StringBuilder();
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Lukket af;Løsningstid (timer)\r\n");
csv.Append("Sag;Kunde;Titel;Kilde;Oprettet (UTC);Lukket (UTC);Løsningstid (timer)\r\n");
foreach (var c in report.Cases)
{
csv.AppendJoin(';',
@ -142,7 +137,6 @@ public static class SupportReport
Field(c.Source == SupportCaseSources.Customer ? "Kunde" : "SEOSoft"),
c.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
c.ClosedAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
Field(c.ClosedByWhom),
c.ResolutionTime?.TotalHours.ToString("0.0", Danish) ?? string.Empty);
csv.Append("\r\n");
}

View file

@ -54,7 +54,6 @@ public static class ManagementReport
Controls(section, data);
Instances(section, data);
Findings(section, data);
ConfigurationChanges(section, data);
About(section, data);
var renderer = new PdfDocumentRenderer { Document = document };
@ -209,10 +208,10 @@ public static class ManagementReport
private static void Controls(Section section, ReportData data)
{
section.AddParagraph("Kontroller", StyleNames.Heading1);
var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt. Tjek manuelt betyder, at kontrollen er relevant, men SQL Server-versionen ikke kan måle den; den skal tjekkes i hånden (se instansens side). Instanser, hvor kontrollen ikke er relevant for SQL Server-versionen, tælles ikke med.");
var intro = section.AddParagraph("Opfyldt betyder, at kontrollen er målt og intet fund er registreret. Ikke målt betyder, at rettighederne eller en fejl forhindrede målingen; det er ikke det samme som opfyldt. Instanser, hvor kontrollen ikke er relevant for SQL Server-versionen, tælles ikke med.");
intro.Style = "Muted";
var table = Table(section, ("Kontrol", 4.6), ("Opfyldt", 1.6), ("Ikke opfyldt", 1.9), ("Ikke målt", 1.7), ("Tjek manuelt", 1.9), ("Instanser, der ikke opfylder", 5.3));
var table = Table(section, ("Kontrol", 5.2), ("Opfyldt", 1.8), ("Ikke opfyldt", 2.1), ("Ikke målt", 1.9), ("Instanser, der ikke opfylder", 6));
foreach (var control in ComplianceControl.All)
{
var statuses = data.Instances.Select(i => (i.Name, Status: control.StatusOn(i.Name, data.RuleStates))).ToList();
@ -223,9 +222,7 @@ public static class ManagementReport
Number(row.Cells[1], statuses.Count(s => s.Status == ControlStatus.Met), Good);
Number(row.Cells[2], failing.Count, failing.Count > 0 ? Critical : Muted);
Number(row.Cells[3], statuses.Count(s => s.Status == ControlStatus.NotMeasured), Muted);
var manual = statuses.Count(s => s.Status == ControlStatus.ManualCheck);
Number(row.Cells[4], manual, manual > 0 ? Medium : Muted);
row.Cells[5].AddParagraph(failing.Count == 0 ? "–" : string.Join(", ", failing.Take(12)) + (failing.Count > 12 ? $" og {failing.Count - 12} flere" : ""));
row.Cells[4].AddParagraph(failing.Count == 0 ? "–" : string.Join(", ", failing.Take(12)) + (failing.Count > 12 ? $" og {failing.Count - 12} flere" : ""));
}
}
@ -276,33 +273,6 @@ public static class ManagementReport
}
}
private static void ConfigurationChanges(Section section, ReportData data)
{
section.AddParagraph("Konfigurationsændringer i perioden", StyleNames.Heading1);
section.AddParagraph("Ændringer af serverindstillinger (sp_configure), trace flags, tjenestekonti, version og tempdb, som SQLInsight Pro har registreret. Tidspunktet er, hvornår ændringen blev opdaget (højst 15 minutter efter).").Style = "Muted";
if (data.ConfigurationChanges.Count == 0)
{
section.AddParagraph("Ingen.");
return;
}
var table = Table(section, ("Opdaget", 2.6), ("Instans", 3.2), ("Indstilling", 5.4), ("Fra", 2.6), ("Til", 2.6));
foreach (var c in data.ConfigurationChanges.Take(MaxFindingRows))
{
var row = table.AddRow();
row.Cells[0].AddParagraph(Date(data, new DateTimeOffset(System.DateTime.SpecifyKind(c.DetectedAt, DateTimeKind.Utc))));
row.Cells[1].AddParagraph(c.InstanceName);
row.Cells[2].AddParagraph($"{c.Area}: {c.Name}");
row.Cells[3].AddParagraph(c.OldValue ?? "–");
row.Cells[4].AddParagraph(c.NewValue ?? "–");
}
if (data.ConfigurationChanges.Count > MaxFindingRows)
{
section.AddParagraph($"… og {data.ConfigurationChanges.Count - MaxFindingRows} flere. Se dem under Konfiguration på instansens side.").Style = "Muted";
}
}
private static void About(Section section, ReportData data)
{
section.AddParagraph("Om rapporten", StyleNames.Heading1);

View file

@ -1,330 +0,0 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
public enum AssessmentLevel
{
/// <summary>Not enough data to say.</summary>
Unknown,
Ok,
/// <summary>"Bemærk": worth knowing, not urgent.</summary>
Note,
/// <summary>"Reagér": act on it.</summary>
Act,
}
/// <param name="Summary">What the numbers say, in one or two sentences.</param>
/// <param name="NextStep">The concrete next action; null when there is nothing to do.</param>
/// <param name="Link">A page in SQLInsight Pro (relative to the instance page) or documentation for the next step.</param>
public sealed record Assessment(AssessmentLevel Level, string Summary, string? NextStep = null, string? Link = null)
{
public string LevelText => MetricAssessment.LevelText(Level);
}
/// <summary>What is known about the instance besides the measurement itself.</summary>
/// <param name="BufferPoolGb">The memory SQL Server may use for data: max server memory, or else the server's memory.</param>
/// <param name="MaxServerMemorySet">False when max server memory is SQL Server's default (unlimited).</param>
public sealed record MetricContext(
double? BufferPoolGb = null,
bool? MaxServerMemorySet = null,
IReadOnlyList<MetricPoint>? BatchRequests = null,
IReadOnlyList<MetricPoint>? CpuSql = null,
IReadOnlyList<MetricPoint>? CpuOther = null)
{
public static MetricContext From(ConfigurationSnapshot? configuration, ServerSnapshot? server,
IReadOnlyList<MetricPoint>? batchRequests = null, IReadOnlyList<MetricPoint>? cpuSql = null, IReadOnlyList<MetricPoint>? cpuOther = null)
{
var maxMb = configuration?.Find("max server memory (MB)")?.ValueInUse;
bool? set = maxMb is null ? null : maxMb < 2147483647;
double? poolMb = set == true ? maxMb : server?.PhysicalMemoryMb is > 0 and var physical ? physical : null;
if (set == true && server?.PhysicalMemoryMb is > 0 and var total)
{
poolMb = Math.Min(poolMb!.Value, total);
}
return new MetricContext(poolMb / 1024.0, set, batchRequests, cpuSql, cpuOther);
}
}
/// <summary>
/// What a measurement means for the customer and how it looks: OK, "Bemærk" or "Reagér", with a concrete next step.
/// Shared by the instance page, the time window analysis and the PDFs, so they all say the same.
/// </summary>
public static class MetricAssessment
{
/// <summary>The classic rule of thumb, scaled: 300 seconds per 4 GB of buffer pool.</summary>
public const double PleSecondsPer4Gb = 300;
/// <summary>A count above 0 in this many consecutive samples is sustained, not a blip.</summary>
public const int Sustained = 3;
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
public static string LevelText(AssessmentLevel level) => level switch
{
AssessmentLevel.Ok => "OK",
AssessmentLevel.Note => "Bemærk",
AssessmentLevel.Act => "Reagér",
_ => "Ukendt",
};
/// <summary>"Hvad betyder det?" for a metric, in plain Danish.</summary>
public static string Explanation(string metric) => metric switch
{
Metrics.CpuSqlServer =>
"Hvor meget af serverens samlede CPU SQL Server bruger. Høj CPU er ikke i sig selv et problem, men vedvarende over 80 % "
+ "betyder, at forespørgsler må vente på at komme til, og at der ikke er luft til spidsbelastninger.",
Metrics.CpuOther =>
"CPU brugt af andre programmer på samme server (antivirus, backup-agenter, applikationer, AOS). Den CPU mangler SQL Server. "
+ "På en dedikeret databaseserver bør den være lav.",
Metrics.BatchRequestsPerSecond =>
"Hvor mange forespørgsler (batches) applikationerne sender pr. sekund. Det er et mål for, hvor travlt serveren har det, "
+ "ikke for et problem i sig selv. Brug det til at se, om en langsom periode skyldes mere arbejde end normalt.",
Metrics.CompilationsPerSecond =>
"Hvor mange gange pr. sekund SQL Server skal lave en ny forespørgselsplan. Planer genbruges normalt; kompilering koster CPU. "
+ "Sammenhold med batch requests: over ca. 10–20 % af batches tyder på forespørgsler, der ikke genbruger planer (literaler i stedet for parametre).",
Metrics.PageLifeExpectancy =>
"Hvor længe (sekunder) en dataside i gennemsnit bliver i hukommelsen, før den skal læses fra disk igen. Højt er godt. "
+ "Tommelfingerreglen er mindst 300 sekunder pr. 4 GB buffer pool. Pludselige fald betyder, at en forespørgsel har læst store mængder data og skubbet andet ud.",
Metrics.UserConnections =>
"Antal forbindelser fra brugere og applikationer. Et mål for brugen; et pludseligt hop kan betyde, at forbindelser ikke lukkes (connection leak), eller at sessioner hænger og venter.",
Metrics.MemoryGrantsPending =>
"Forespørgsler, der venter på at få hukommelse til sortering og hash, før de overhovedet kan starte. Bør altid være 0; "
+ "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).",
Metrics.ProcessesBlocked =>
"Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.",
"disk.read" =>
"Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.",
"disk.write.log" =>
"Gennemsnitlig tid pr. skrivning til transaktionsloggen. Hver commit venter på den, så den skal være lav: under 5 ms, helst 1–2 ms.",
"disk.write" =>
"Gennemsnitlig tid pr. skrivning til datafilerne. Skrivninger til datafiler sker mest i baggrunden (checkpoint), så de tåler mere end loggen; over 20 ms er højt.",
_ => "Ingen forklaring.",
};
/// <summary>The assessment of a metric's samples in a period.</summary>
public static Assessment Assess(string metric, IReadOnlyList<MetricPoint> points, MetricContext? context = null)
{
ArgumentNullException.ThrowIfNull(points);
context ??= new MetricContext();
if (points.Count == 0)
{
return new(AssessmentLevel.Unknown, "Ingen målinger i perioden.");
}
return metric switch
{
Metrics.CpuSqlServer => Cpu(points, context.CpuOther),
Metrics.CpuOther => CpuOther(points, context.CpuSql),
Metrics.PageLifeExpectancy => Ple(points, context),
Metrics.CompilationsPerSecond => Compilations(points, context.BatchRequests),
Metrics.MemoryGrantsPending => SustainedCount(points,
"Ingen forespørgsler har ventet på hukommelse.",
"forespørgsler ventede på hukommelse",
"Find forespørgslerne med store memory grants (ventetypen RESOURCE_SEMAPHORE), opdatér statistikker og tilføj indexes, så de sorterer mindre; tjek max server memory.",
"ventetyper/RESOURCE_SEMAPHORE"),
Metrics.ProcessesBlocked => SustainedCount(points,
"Ingen blokerede processer.",
"sessioner var blokeret",
"Se blocking-kæderne for, hvem der blokerer (head blocker), og hvilken sætning den kører; hold transaktioner korte.",
"analyse#blocking"),
Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok,
$"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."),
Metrics.UserConnections => Connections(points),
_ => new(AssessmentLevel.Unknown, "Ingen vurdering."),
};
}
/// <summary>Disk latency on a drive: data reads, data writes, log writes.</summary>
public static Assessment AssessDisk(DiskActivity disk)
{
ArgumentNullException.ThrowIfNull(disk);
var worst = AssessmentLevel.Unknown;
var parts = new List<string>();
if (disk.AverageReadMs is { } read && disk.Reads >= 100)
{
var level = read > 20 ? AssessmentLevel.Act : read > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok;
worst = Max(worst, level);
parts.Add($"læsning {N(read, 1)} ms");
}
if (disk.AverageWriteMs is { } write && disk.Writes >= 100)
{
var logOnly = disk.DataFiles == 0;
var level = logOnly
? write > 15 ? AssessmentLevel.Act : write > 5 ? AssessmentLevel.Note : AssessmentLevel.Ok
: write > 20 ? AssessmentLevel.Act : write > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok;
worst = Max(worst, level);
parts.Add($"skrivning {N(write, 1)} ms{(logOnly ? " (log)" : "")}");
}
if (worst == AssessmentLevel.Unknown)
{
return new(AssessmentLevel.Unknown, "For lidt I/O i perioden til at vurdere.");
}
var summary = $"{disk.Volume}: {string.Join(", ", parts)}.";
return worst switch
{
AssessmentLevel.Act => new(worst, summary + " Brugerne mærker det.",
"Tjek om disken er overbelastet (mange forespørgsler, der læser meget) eller langsom (storage, SAN, virtuel disk). Find de forespørgsler, der læser mest, før der købes hurtigere disk.",
"analyse"),
AssessmentLevel.Note => new(worst, summary + " Lidt højt.", "Hold øje med, om det følger bestemte tidspunkter (backup, indexvedligehold, batchjob).", "analyse"),
_ => new(worst, summary + " Godt."),
};
}
private static Assessment Cpu(IReadOnlyList<MetricPoint> sql, IReadOnlyList<MetricPoint>? other)
{
IReadOnlyList<MetricPoint> total = other is { Count: > 0 } ? Combine(sql, other) : sql;
var avg = Average(total);
var otherAvg = other is { Count: > 0 } ? Average(other) : 0;
var highRun = LongestRun(total, v => v > 80);
var summary = $"I gennemsnit {N(avg, 0)} % samlet CPU, højst {N(Peak(total), 0)} %"
+ (other is { Count: > 0 } ? $"; heraf {N(Average(sql), 0)} % SQL Server og {N(otherAvg, 0)} % andre processer." : ".");
if (highRun >= Sustained)
{
return otherAvg >= 20 || otherAvg > Average(sql)
? new(AssessmentLevel.Act, summary + " CPU'en er vedvarende over 80 %, og en stor del bruges af andre processer.",
"Find hvilke programmer på serveren der bruger CPU'en (Jobliste/Ressourceovervågning): antivirus uden undtagelser for databasefiler, backup-agenter eller en AOS på samme server.")
: new(AssessmentLevel.Act, summary + " SQL Server bruger vedvarende over 80 % af CPU'en.",
"Find forespørgslerne med højest CPU i analysen af tidsrummet og optimér dem (indexes, implicitte konverteringer); tjek MAXDOP og cost threshold for parallelism.",
"analyse?sortering=cpu");
}
return avg > 60
? new(AssessmentLevel.Note, summary + " Højt, men ikke vedvarende over 80 %.", "Hold øje med spidsbelastninger, og se hvilke forespørgsler der bruger CPU'en.", "analyse?sortering=cpu")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment CpuOther(IReadOnlyList<MetricPoint> other, IReadOnlyList<MetricPoint>? sql)
{
var avg = Average(other);
var summary = $"Andre processer brugte i gennemsnit {N(avg, 0)} % af CPU'en, højst {N(Peak(other), 0)} %.";
if (LongestRun(other, v => v > 30) >= Sustained || avg >= 20 && (sql is not { Count: > 0 } || avg > Average(sql)))
{
return new(AssessmentLevel.Act, summary + " Det er meget for en databaseserver.",
"Find programmet på serveren (Jobliste/Ressourceovervågning). Typisk antivirus uden undtagelser for .mdf/.ldf, backup-software eller en applikation på samme maskine.");
}
return avg >= 10
? new(AssessmentLevel.Note, summary, "Undersøg hvilke programmer der kører på serveren ud over SQL Server.")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment Ple(IReadOnlyList<MetricPoint> points, MetricContext context)
{
var threshold = Math.Max(PleSecondsPer4Gb, PleSecondsPer4Gb * (context.BufferPoolGb ?? 4) / 4);
var avg = Average(points);
var min = points.Min(p => p.Value);
var median = Median(points);
var dip = median > 0 && min < median * 0.5;
var memory = context.MaxServerMemorySet == false
? " max server memory er ikke sat, så SQL Server kan tage hukommelse fra Windows og andre programmer; sæt den (se konfigurationen)."
: "";
var summary = $"I gennemsnit {N(avg, 0)} s, laveste {N(min, 0)} s. Grænsen er {N(threshold, 0)} s"
+ (context.BufferPoolGb is { } gb ? $" ({N(PleSecondsPer4Gb, 0)} s pr. 4 GB af {N(gb, 0)} GB buffer pool)." : " (300 s pr. 4 GB buffer pool).");
if (avg < threshold)
{
return new(AssessmentLevel.Act, summary + " Data bliver ikke længe i hukommelsen, så der læses meget fra disk." + memory,
"Find forespørgslerne, der læser mest (analysen sorteret efter læsninger), og giv dem bedre indexes; overvej mere hukommelse, når de er effektive.",
"analyse?sortering=laesninger");
}
if (dip || min < threshold)
{
return new(AssessmentLevel.Note, summary + $" Faldt{(dip ? " med mere end 50 %" : "")} undervejs: en forespørgsel har læst store mængder data og skubbet andet ud af hukommelsen." + memory,
"Find tidspunktet for faldet i grafen og se, hvilke forespørgsler der læste mest i det tidsrum.", "analyse?sortering=laesninger");
}
return memory.Length > 0
? new(AssessmentLevel.Note, summary + memory, "Sæt max server memory.", "konfiguration")
: new(AssessmentLevel.Ok, summary + " Godt.");
}
private static Assessment Compilations(IReadOnlyList<MetricPoint> compilations, IReadOnlyList<MetricPoint>? batches)
{
var avg = Average(compilations);
if (batches is not { Count: > 0 } || Average(batches) <= 0)
{
return new(AssessmentLevel.Unknown, $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund; batch requests mangler til sammenligning.");
}
var ratio = avg / Average(batches);
var summary = $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund = {N(100 * ratio, 0)} % af batches.";
if (Average(batches) < 5)
{
return new(AssessmentLevel.Ok, summary + " Så lidt arbejde, at andelen ikke betyder noget.");
}
return ratio switch
{
> 0.2 => new(AssessmentLevel.Act, summary + " Mange forespørgsler får en ny plan hver gang, hvilket koster CPU.",
"Find ad hoc-forespørgsler med literaler i plan cache; brug parametre, slå 'optimize for ad hoc workloads' til, og overvej forced parameterization for databasen. I AX: undgå 'Use literals'.",
"ventetyper/RESOURCE_SEMAPHORE_QUERY_COMPILE"),
> 0.1 => new(AssessmentLevel.Note, summary + " Lidt højt.", "Hold øje med CPU; se efter forespørgsler med literaler."),
_ => new(AssessmentLevel.Ok, summary + " Planer genbruges fint."),
};
}
private static Assessment SustainedCount(IReadOnlyList<MetricPoint> points, string none, string what, string action, string link)
{
var run = LongestRun(points, v => v > 0);
var peak = Peak(points);
if (run == 0)
{
return new(AssessmentLevel.Ok, none);
}
var summary = $"{N(peak, 0)} {what} (højst); over 0 i {points.Count(p => p.Value > 0)} af {points.Count} målinger.";
return run >= Sustained
? new(AssessmentLevel.Act, summary + " Det varede ved.", action, link)
: new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link);
}
private static Assessment Connections(IReadOnlyList<MetricPoint> points)
{
var avg = Average(points);
var peak = Peak(points);
var summary = $"I gennemsnit {N(avg, 0)} forbindelser, højst {N(peak, 0)}.";
return avg >= 10 && peak > 2 * avg
? new(AssessmentLevel.Note, summary + " Antallet steg kraftigt undervejs.", "Se om det skyldes flere brugere, eller sessioner der hænger (blocking) eller ikke lukkes.", "analyse#blocking")
: new(AssessmentLevel.Ok, summary);
}
private static List<MetricPoint> Combine(IReadOnlyList<MetricPoint> a, IReadOnlyList<MetricPoint> b)
{
var other = b.GroupBy(p => p.At).ToDictionary(g => g.Key, g => g.First().Value);
return a.Select(p => p with { Value = p.Value + (other.TryGetValue(p.At, out var v) ? v : 0) }).ToList();
}
private static int LongestRun(IReadOnlyList<MetricPoint> points, Func<double, bool> predicate)
{
int best = 0, run = 0;
foreach (var p in points.OrderBy(p => p.At))
{
run = predicate(p.Value) ? run + 1 : 0;
best = Math.Max(best, run);
}
return best;
}
private static double Average(IReadOnlyList<MetricPoint> points) => points.Count == 0 ? 0 : points.Average(p => p.Value);
private static double Peak(IReadOnlyList<MetricPoint> points) => points.Count == 0 ? 0 : points.Max(p => p.Value);
private static double Median(IReadOnlyList<MetricPoint> points)
{
var sorted = points.Select(p => p.Value).Order().ToList();
return sorted.Count == 0 ? 0 : sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2;
}
private static AssessmentLevel Max(AssessmentLevel a, AssessmentLevel b) => (AssessmentLevel)Math.Max((int)a, (int)b);
private static string N(double value, int decimals) => value.ToString("N" + decimals, Danish);
}

View file

@ -1,274 +0,0 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using SQLInsight.Core.Data;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
/// <summary>One instance in the privilege audit: the latest collection and the changes in the period.</summary>
public sealed record PrivilegeAuditInstance(InstanceOverview Instance, PrivilegeSnapshot? Snapshot, IReadOnlyList<StoredPrivilegeChange> Changes)
{
public IReadOnlyList<PrivilegedAccount> Sysadmins => Snapshot is null ? [] : PrivilegeAnalysis.Accounts(Snapshot, PrivilegeAnalysis.Sysadmin);
public SqlLoginInfo? Sa => Snapshot?.SqlLogins.FirstOrDefault(l => l.IsSa);
/// <summary>Enabled SQL logins that do not follow Windows' password policy (sa included).</summary>
public IReadOnlyList<SqlLoginInfo> LoginsWithoutPolicy =>
Snapshot?.SqlLogins.Where(l => !l.IsDisabled && !l.PolicyChecked && !PrivilegeAnalysis.IsServiceIdentity(l.Name)).ToList() ?? [];
}
/// <summary>
/// The privilege audit for the auditors: who has elevated server rights now (Windows groups unfolded), what changed in
/// the period and by whom, sa, SQL logins without password policy and the default trace. Contains names (customer
/// data): produced locally in the web UI and never sent anywhere.
/// </summary>
public sealed record PrivilegeAudit(DateTimeOffset From, DateTimeOffset To, IReadOnlyList<PrivilegeAuditInstance> Instances)
{
public static async Task<PrivilegeAudit> LoadAsync(InsightRepository repository, InstanceOverview? instance, DateTimeOffset from, DateTimeOffset to,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
var instances = instance is null ? await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false) : [instance];
var changes = await repository.GetPrivilegeChangesAsync(instance?.InstanceId, from, to, 5000, cancellationToken).ConfigureAwait(false);
var result = new List<PrivilegeAuditInstance>();
foreach (var i in instances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase))
{
var snapshot = await repository.GetSnapshotAsync<PrivilegeSnapshot>(i.InstanceId, "privileges", cancellationToken).ConfigureAwait(false);
result.Add(new PrivilegeAuditInstance(i, snapshot, changes.Where(c => c.InstanceId == i.InstanceId).ToList()));
}
return new PrivilegeAudit(from, to, result);
}
/// <summary>The audit's statement of where the data comes from; shared with the web page.</summary>
public const string MethodText =
"Rettighederne læses af SQLInsight Agent hvert 15. minut fra SQL Servers egne kataloger: medlemmer af de faste serverroller "
+ "(sys.server_role_members) og CONTROL SERVER (sys.server_permissions), loginenes type og status (sys.server_principals) og "
+ "SQL-loginenes adgangskodepolitik (sys.sql_logins). Windows-grupper foldes rekursivt ud til personerne i dem ved at spørge Active "
+ "Directory fra agentens maskine; kan en gruppe ikke foldes ud, står årsagen ved gruppen, og dens medlemmer tælles ikke. "
+ "Ændringer findes ved at sammenligne hver måling med den forrige; den første måling efter installation er udgangspunktet. "
+ "Hvem der foretog en ændring af en serverrolle, og hvornår, læses fra SQL Servers default trace (hændelse 108), når agenten har "
+ "rettigheden ALTER TRACE; default trace gemmer kun de seneste hændelser (typisk dage), så et hul i agentens drift kan betyde, at "
+ "hvem/hvornår er ukendt. Ændringer i Windows-gruppers medlemmer registreres, når de opdages, uden hvem i Active Directory der foretog dem. "
+ "Alle navne bliver i kundens egen SQLInsight-database og sendes ikke til SQLInsight Hub.";
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
private static readonly Color Text = new(0x10, 0x18, 0x28);
private static readonly Color Muted = new(0x66, 0x70, 0x85);
private static readonly Color Border = new(0xE4, 0xE7, 0xEC);
private static readonly Color Surface = new(0xF5, 0xF7, 0xFA);
private static readonly Color Brand = new(0x0E, 0x74, 0x90);
private static readonly Color Bad = new(0xD9, 0x2D, 0x20);
public byte[] Render(string organization, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
InterFontResolver.Install();
var document = new Document();
document.Info.Title = $"Rettighedsrevision {Time(From, zone)} – {Time(To, zone)}";
document.Info.Author = "SQLInsight Pro";
Styles(document);
var section = document.AddSection();
section.PageSetup = document.DefaultPageSetup.Clone();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = Unit.FromCentimeter(2);
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);
section.PageSetup.LeftMargin = Unit.FromCentimeter(1.8);
section.PageSetup.RightMargin = Unit.FromCentimeter(1.8);
section.Headers.Primary.AddParagraph($"SQLInsight Pro · {organization} · Fortroligt: indeholder navne").Style = "Muted";
var footer = section.Footers.Primary.AddParagraph("Rettighedsrevision Side ");
footer.Style = "Muted";
footer.AddPageField();
footer.AddText(" af ");
footer.AddNumPagesField();
var title = section.AddParagraph("Rettighedsrevision");
title.Format.Font.Size = 20;
title.Format.Font.Bold = true;
section.AddParagraph($"{organization} · {Time(From, zone)} – {Time(To, zone)} · {Count(Instances.Count, "instans", "instanser")}").Format.Font.Color = Brand;
if (Instances.Count == 0)
{
section.AddParagraph("Ingen instanser overvåges.");
}
foreach (var entry in Instances)
{
Instance(section, entry, zone);
}
section.AddParagraph("Datakilde og metode", StyleNames.Heading1);
section.AddParagraph(MethodText);
section.AddParagraph($"Udskrevet {Time(DateTimeOffset.UtcNow, zone)} ({zone.DisplayName}).", "Muted");
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var output = new MemoryStream();
renderer.PdfDocument.Save(output, false);
return output.ToArray();
}
private static void Instance(Section section, PrivilegeAuditInstance entry, TimeZoneInfo zone)
{
section.AddParagraph(entry.Instance.Name, StyleNames.Heading1);
var snapshot = entry.Snapshot;
if (snapshot is null)
{
section.AddParagraph("Rettighederne er endnu ikke målt på denne instans (måles hvert 15. minut fra version 1.0.5).", "Muted");
return;
}
section.AddParagraph($"Målt {Time(snapshot.CapturedAt, zone)}.", "Muted");
var sysadmins = entry.Sysadmins;
var summary = section.AddParagraph($"Personer og konti med sysadmin: {sysadmins.Count} (grænse {snapshot.MaxSysadminAccounts}).");
if (sysadmins.Count > snapshot.MaxSysadminAccounts)
{
summary.Format.Font.Color = Bad;
}
section.AddParagraph("Nuværende rettigheder", "Heading2");
var table = Table(section, [3.2, 5.6, 3, 5.6]);
Header(table, "Rettighed", "Login eller gruppe", "Type", "Personer (efter udfoldning)");
foreach (var p in snapshot.Principals.OrderBy(p => PrivilegeAnalysis.Privileges.ToList().IndexOf(p.Privilege)).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
{
Row(table, PrivilegeAnalysis.IsHighRisk(p.Privilege) && !p.IsDisabled, p.Privilege, p.Name + (p.IsDisabled ? " (deaktiveret)" : ""), p.Type, Members(p));
}
section.AddParagraph("Ændringer i perioden", "Heading2");
if (entry.Changes.Count == 0)
{
section.AddParagraph("Ingen ændringer registreret i perioden.", "Muted");
}
else
{
var changes = Table(section, [2.8, 1.7, 2.6, 4.4, 3.2, 2.7]);
Header(changes, "Opdaget", "Ændring", "Rettighed", "Hvem", "Ændret af", "Ændret");
foreach (var c in entry.Changes.OrderBy(c => c.DetectedAt))
{
Row(changes, c.Kind == PrivilegeAnalysis.Added && PrivilegeAnalysis.IsHighRisk(c.Privilege),
Time(new DateTimeOffset(c.DetectedAt, TimeSpan.Zero), zone), c.Kind, c.Privilege,
c.Principal + (c.ViaGroup is null ? "" : $" (via {c.ViaGroup})"),
c.ChangedBy ?? (c.ViaGroup is null ? "ukendt" : "Active Directory"),
c.ChangedAt is { } at ? Time(new DateTimeOffset(at, TimeSpan.Zero), zone) : "–");
}
}
section.AddParagraph("Kontroller", "Heading2");
var controls = Table(section, [5.5, 11.9]);
Row(controls, entry.Sa is { IsDisabled: false }, "sa-kontoen", SaText(entry.Sa));
var withoutPolicy = entry.LoginsWithoutPolicy;
Row(controls, withoutPolicy.Count > 0, "SQL-logins uden adgangskodepolitik",
withoutPolicy.Count == 0 ? "Ingen" : string.Join(", ", withoutPolicy.Select(l => l.Name)));
var withoutExpiration = snapshot.SqlLogins.Count(l => !l.IsDisabled && l.PolicyChecked && !l.ExpirationChecked);
Row(controls, false, "SQL-logins med politik men uden udløb", withoutExpiration.ToString(Danish));
Row(controls, snapshot.DefaultTraceEnabled == false, "Default trace", TraceText(snapshot));
var unknown = snapshot.Principals.Where(p => p.Type == "Windows-gruppe" && p.Members is null && !p.IsDisabled).ToList();
if (unknown.Count > 0)
{
Row(controls, true, "Grupper der ikke kunne foldes ud", string.Join("; ", unknown.Select(g => $"{g.Name}: {g.ExpansionError}")));
}
}
public static string SaText(SqlLoginInfo? sa) => sa switch
{
null => "Findes ikke som SQL-login (kun Windows-godkendelse, eller omdøbt og ikke genkendt)",
{ IsDisabled: true } => "Deaktiveret",
{ PolicyChecked: false } => "Aktiv, uden adgangskodepolitik",
_ => "Aktiv, med adgangskodepolitik",
};
public static string TraceText(PrivilegeSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
var state = snapshot.DefaultTraceEnabled switch
{
true => "Slået til",
false => "Slået fra: hvem der ændrer serverroller, kan ikke ses",
null => "Ukendt",
};
return snapshot.TraceStatus is { } why ? $"{state}. {why}" : state;
}
private static string Members(PrivilegedPrincipal p) => p switch
{
{ Type: not "Windows-gruppe" } => "–",
{ Members: null } => $"kunne ikke foldes ud: {p.ExpansionError}",
{ Members.Count: 0 } => "ingen medlemmer",
_ => string.Join(", ", p.Members.Select(m => m.Account + (m.Enabled ? "" : " (deaktiveret)"))),
};
private static string Count(int n, string one, string many) => $"{n.ToString(Danish)} {(n == 1 ? one : many)}";
private static string Time(DateTimeOffset utc, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(utc, zone).ToString("dd-MM-yyyy HH:mm", Danish);
private static void Styles(Document document)
{
var normal = document.Styles[StyleNames.Normal]!;
normal.Font.Name = InterFontResolver.Family;
normal.Font.Size = 9;
normal.Font.Color = Text;
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
var heading = document.Styles[StyleNames.Heading1]!;
heading.Font.Size = 13;
heading.Font.Bold = true;
heading.ParagraphFormat.SpaceBefore = Unit.FromPoint(14);
heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(5);
heading.ParagraphFormat.KeepWithNext = true;
var sub = document.Styles[StyleNames.Heading2]!;
sub.Font.Size = 10.5;
sub.Font.Bold = true;
sub.ParagraphFormat.SpaceBefore = Unit.FromPoint(8);
sub.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
sub.ParagraphFormat.KeepWithNext = true;
var muted = document.Styles.AddStyle("Muted", StyleNames.Normal);
muted.Font.Color = Muted;
muted.Font.Size = 8;
}
private static Table Table(Section section, double[] widthsCm)
{
var table = section.AddTable();
table.Borders.Bottom = new Border { Width = 0.5, Color = Border };
foreach (var width in widthsCm)
{
table.AddColumn(Unit.FromCentimeter(width));
}
return table;
}
private static void Header(Table table, params string[] titles)
{
var row = table.AddRow();
row.HeadingFormat = true;
row.Shading.Color = Surface;
row.Format.Font.Bold = true;
for (var i = 0; i < titles.Length; i++)
{
row.Cells[i].AddParagraph(titles[i]);
}
}
private static void Row(Table table, bool bad, params string[] values)
{
var row = table.AddRow();
row.Borders.Bottom = new Border { Width = 0.25, Color = Border };
for (var i = 0; i < values.Length; i++)
{
var paragraph = row.Cells[i].AddParagraph(values[i]);
if (bad)
{
paragraph.Format.Font.Color = Bad;
}
}
}
}

View file

@ -19,9 +19,6 @@ public sealed record ReportData
public required PeriodStats Period { get; init; }
public string? RulePack { get; init; }
/// <summary>Configuration changes detected in the period (sp_configure, trace flags, service accounts …).</summary>
public IReadOnlyList<StoredConfigurationChange> ConfigurationChanges { get; init; } = [];
public string Version { get; init; } =
typeof(ReportData).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
@ -46,7 +43,6 @@ public sealed record ReportData
RuleStates = await repository.GetAllRuleStatesAsync(cancellationToken).ConfigureAwait(false),
Period = await repository.GetPeriodStatsAsync(from, to, cancellationToken).ConfigureAwait(false),
RulePack = (await repository.GetUpdateStatusAsync(cancellationToken).ConfigureAwait(false))?.RulePackVersion,
ConfigurationChanges = await repository.GetConfigurationChangesAsync(null, from, to, 500, cancellationToken).ConfigureAwait(false),
};
}
}
@ -83,13 +79,7 @@ public sealed record ComplianceControl(string Name, string Description, params s
return ControlStatus.NotMet;
}
// The core rule applies but cannot be measured on this version (CHECKDB status before 2016 SP2 …): check by hand.
if (relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.ManualCheck))
{
return ControlStatus.ManualCheck;
}
// The core rule does not apply to this version: neither met nor a gap.
// The core rule does not apply to this version (an integrity check that needs 2016 SP2 on 2005 …): neither met nor a gap.
if (relevant.Any(s => s.RuleId == Rules[0] && s.Outcome == RuleOutcome.NotApplicable))
{
return ControlStatus.NotApplicable;
@ -109,7 +99,4 @@ public enum ControlStatus
/// <summary>The control does not apply to the instance's SQL Server version; counted neither as met nor as not measured.</summary>
NotApplicable,
/// <summary>Applies, but this version cannot measure it: to be checked by hand; not counted as not measured.</summary>
ManualCheck,
}

View file

@ -162,38 +162,4 @@ public static class WindowBaseline
var values = earlierWeeks.SelectMany(w => w).Select(p => p.Value).ToList();
return values.Count == 0 ? null : values.Average();
}
/// <summary>
/// Each drive in the window against the same hours of earlier weeks: all earlier weeks' I/O pooled, so latency is
/// weighted by the number of I/Os and rates by the time measured.
/// </summary>
public static IReadOnlyList<DiskComparison> CompareDisks(IReadOnlyList<Core.Data.DiskActivity> window, IReadOnlyList<IReadOnlyList<Core.Data.DiskActivity>> earlierWeeks)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(earlierWeeks);
return window
.Select(d =>
{
var earlier = earlierWeeks.SelectMany(w => w).Where(e => string.Equals(e.Volume, d.Volume, StringComparison.OrdinalIgnoreCase)).ToList();
Core.Data.DiskActivity? normal = earlier.Count == 0 || earlier.Sum(e => e.Seconds) <= 0
? null
: new Core.Data.DiskActivity(d.Volume, 0, 0, earlier.Sum(e => e.Reads), earlier.Sum(e => e.Writes), earlier.Sum(e => e.BytesRead),
earlier.Sum(e => e.BytesWritten), earlier.Sum(e => e.ReadStallMs), earlier.Sum(e => e.WriteStallMs), earlier.Sum(e => e.Seconds));
return new DiskComparison(d, normal);
})
.ToList();
}
}
/// <summary>A drive's I/O in the window compared with the same hours of the week in the previous weeks.</summary>
public sealed record DiskComparison(Core.Data.DiskActivity Window, Core.Data.DiskActivity? Normal)
{
/// <summary>Data files over 20 ms per read or log files over 5 ms per write: slow whatever the history says.</summary>
public bool IsSlow => Window.AverageReadMs >= 20 && Window.Reads >= 100 || Window.AverageWriteMs >= (Window.DataFiles == 0 ? 5 : 20) && Window.Writes >= 100;
/// <summary>Latency at least twice the normal (and noticeably so), or a slow drive.</summary>
public bool IsUnusual => IsSlow
|| Worse(Window.AverageReadMs, Normal?.AverageReadMs) || Worse(Window.AverageWriteMs, Normal?.AverageWriteMs);
private static bool Worse(double? now, double? normal) => now is { } n && normal is > 0 and var m && n >= m * 2 && n - m >= 2;
}

View file

@ -16,46 +16,16 @@ public sealed record TimeWindowAnalysis
public required IReadOnlyList<MetricComparison> Metrics { get; init; }
public required IReadOnlyList<WaitComparison> Waits { get; init; }
public required IReadOnlyList<FileLatency> Files { get; init; }
/// <summary>Disk speed per drive, against the normal for the same hours.</summary>
public IReadOnlyList<DiskComparison> Disks { get; init; } = [];
public required IReadOnlyList<StoredBlocking> Blocking { get; init; }
public required IReadOnlyList<StoredDeadlock> Deadlocks { get; init; }
/// <summary>Backup, restore, DBCC and other operations with progress that ran in the window.</summary>
public IReadOnlyList<StoredLongOperation> LongOperations { get; init; } = [];
/// <summary>
/// A statement's CPU time as a share of the window, in percent of one CPU core: 100 means one core all the time.
/// </summary>
public double ActivityPercent(TopQuery query)
{
ArgumentNullException.ThrowIfNull(query);
var seconds = (To - From).TotalSeconds;
return seconds <= 0 ? 0 : 100.0 * query.WorkerTimeUs / (seconds * 1_000_000);
}
public required IReadOnlyList<StoredFinding> Findings { get; init; }
public required TopQueryOrder QueryOrder { get; init; }
/// <summary>The heaviest statements. Their text is customer data: only for the local UI and PDF.</summary>
public required IReadOnlyList<TopQuery> TopQueries { get; init; }
/// <summary>What the assessment of the measurements needs besides them (buffer pool size, max server memory).</summary>
public MetricContext Context { get; init; } = new();
/// <summary>The assessment of a measurement in the window (OK, Bemærk, Reagér).</summary>
public Assessment Assess(MetricComparison metric)
{
ArgumentNullException.ThrowIfNull(metric);
return MetricAssessment.Assess(metric.Metric, metric.Points, Context);
}
private static IReadOnlyList<MetricPoint> Points(List<MetricComparison> metrics, string metric) =>
metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? [];
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0
|| Deadlocks.Count > 0 || TopQueries.Count > 0 || LongOperations.Count > 0;
public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Blocking.Count > 0
|| Deadlocks.Count > 0 || TopQueries.Count > 0;
/// <summary>The metrics shown, in order: key, label, unit, decimals, lower-is-worse, fixed maximum.</summary>
public static readonly IReadOnlyList<(string Metric, string Label, string Unit, int Decimals, bool LowerIsWorse, double? Max)> MetricDefinitions =
@ -110,17 +80,11 @@ public sealed record TimeWindowAnalysis
}
var earlierWaits = new List<IReadOnlyList<HourlyWaitTotal>>();
var earlierDisks = new List<IReadOnlyList<Core.Data.DiskActivity>>();
foreach (var back in weeks)
{
earlierWaits.Add(await repository.GetWaitsHourlyAsync(id, from - back, to - back, cancellationToken).ConfigureAwait(false));
earlierDisks.Add(await repository.GetDiskActivityHourlyAsync(id, from - back, to - back, cancellationToken).ConfigureAwait(false));
}
var windowDisks = resolution == WindowResolution.Raw
? await repository.GetDiskActivityAsync(id, from, to, cancellationToken).ConfigureAwait(false)
: await repository.GetDiskActivityHourlyAsync(id, from, to, cancellationToken).ConfigureAwait(false);
return new TimeWindowAnalysis
{
Instance = instance,
@ -129,16 +93,10 @@ public sealed record TimeWindowAnalysis
Resolution = resolution,
RawDays = rawDays,
Metrics = metrics,
Context = MetricContext.From(
await repository.GetSnapshotAsync<Core.Data.ConfigurationSnapshot>(id, "configuration", cancellationToken).ConfigureAwait(false),
await repository.GetSnapshotAsync<Core.Data.ServerSnapshot>(id, "server", cancellationToken).ConfigureAwait(false),
Points(metrics, Repository.Metrics.BatchRequestsPerSecond), Points(metrics, Repository.Metrics.CpuSqlServer), Points(metrics, Repository.Metrics.CpuOther)),
Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits),
Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false),
Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks),
Blocking = await repository.GetBlockingAsync(id, from, to, cancellationToken).ConfigureAwait(false),
Deadlocks = await repository.GetDeadlocksAsync(id, from, to, cancellationToken).ConfigureAwait(false),
LongOperations = await repository.GetLongOperationsAsync(id, from, to, 100, cancellationToken).ConfigureAwait(false),
Findings = await repository.GetFindingsActiveDuringAsync(id, from, to, cancellationToken).ConfigureAwait(false),
QueryOrder = order,
TopQueries = await repository.GetTopQueriesAsync(id, from, to, order, 25, cancellationToken).ConfigureAwait(false),

View file

@ -65,9 +65,7 @@ public static class TimeWindowReport
Metrics(section, analysis);
Waits(section, analysis);
TopQueries(section, analysis);
Disks(section, analysis);
Files(section, analysis);
LongOperations(section, analysis, zone);
Blocking(section, analysis, zone);
Deadlocks(section, analysis, zone);
Findings(section, analysis);
@ -100,22 +98,6 @@ public static class TimeWindowReport
Row(table, m.IsUnusual, m.Label, Number(m.Average, m.Decimals) + m.Unit, Number(m.Peak, m.Decimals) + m.Unit,
m.NormalAverage is { } n ? Number(n, m.Decimals) + m.Unit : "–", m.IsUnusual ? "afviger" : "");
}
section.AddParagraph("Vurdering", StyleNames.Heading2);
Assessments(section, analysis.Metrics.Where(m => m.Points.Count > 0).Select(m => (m.Label, analysis.Assess(m))));
}
/// <summary>The assessment of each measurement (OK, Bemærk, Reagér) with the next step, as on the web page.</summary>
private static void Assessments(Section section, IEnumerable<(string Label, Assessment Assessment)> items)
{
foreach (var (label, assessment) in items.Where(i => i.Assessment.Level != AssessmentLevel.Unknown))
{
var paragraph = section.AddParagraph();
var level = paragraph.AddFormattedText($"{assessment.LevelText} ", TextFormat.Bold);
level.Color = assessment.Level switch { AssessmentLevel.Act => Unusual, AssessmentLevel.Note => new Color(0xB5, 0x47, 0x08), _ => new Color(0x07, 0x94, 0x55) };
paragraph.AddFormattedText(label + ": ", TextFormat.Bold);
paragraph.AddText(assessment.Summary + (assessment.NextStep is { } next ? " Næste skridt: " + next : ""));
}
}
private static void Waits(Section section, TimeWindowAnalysis analysis)
@ -162,30 +144,6 @@ public static class TimeWindowReport
}
}
private static void Disks(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("Diskhastighed pr. drev", StyleNames.Heading1);
if (analysis.Disks.Count == 0)
{
section.AddParagraph("Ingen målinger pr. drev i tidsrummet (indsamles fra version 1.0.5).", "Muted");
return;
}
var table = Table(section, [3.2, 2.2, 2.2, 2.2, 2.2, 2.6, 2.8]);
Header(table, "Drev", "Læs IOPS", "Skriv IOPS", "Læs MB/s", "Skriv MB/s", "Gns. læs", "Gns. skriv");
foreach (var d in analysis.Disks)
{
var w = d.Window;
Row(table, d.IsUnusual, w.Volume, Number(w.ReadIops, 1), Number(w.WriteIops, 1), Number(w.ReadMbPerSecond, 1), Number(w.WriteMbPerSecond, 1),
Ms(w.AverageReadMs) + Normal(d.Normal?.AverageReadMs), Ms(w.AverageWriteMs) + Normal(d.Normal?.AverageWriteMs));
}
section.AddParagraph("Latens i parentes: det normale for samme klokkeslæt de foregående uger.", "Muted");
Assessments(section, analysis.Disks.Select(d => (d.Window.Volume, MetricAssessment.AssessDisk(d.Window))));
static string Normal(double? value) => value is { } v ? $" ({v.ToString("N1", Danish)})" : "";
}
private static void Files(Section section, TimeWindowAnalysis analysis)
{
section.AddParagraph("I/O-latens pr. fil", StyleNames.Heading1);
@ -204,23 +162,6 @@ public static class TimeWindowReport
}
}
private static void LongOperations(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{
if (analysis.LongOperations.Count == 0)
{
return;
}
section.AddParagraph("Langvarige operationer", StyleNames.Heading1);
var table = Table(section, [3.4, 4.2, 3.2, 2.6, 2.4]);
Header(table, "Kommando", "Database", "Startet", "Varighed", "Færdig");
foreach (var o in analysis.LongOperations)
{
Row(table, false, o.Command, o.DatabaseName ?? "–", Time(new DateTimeOffset(o.StartedAt, TimeSpan.Zero), zone),
Number(o.Duration.TotalMinutes, 0) + " min", Number(o.PercentComplete, 1) + " %");
}
}
private static void Blocking(Section section, TimeWindowAnalysis analysis, TimeZoneInfo zone)
{
section.AddParagraph("Blocking", StyleNames.Heading1);

View file

@ -1,21 +0,0 @@
using Dapper;
namespace SQLInsight.Repository;
public sealed record StoredConfigurationChange(long ChangeId, int InstanceId, string InstanceName, DateTime DetectedAt, string Area, string Name, string? OldValue, string? NewValue);
public sealed partial class InsightRepository
{
/// <summary>Configuration changes, newest first: for one instance (or all), detected in [from, to].</summary>
public async Task<IReadOnlyList<StoredConfigurationChange>> GetConfigurationChangesAsync(int? instanceId, DateTimeOffset from, DateTimeOffset to, int top = 500, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredConfigurationChange>("""
SELECT TOP (@top) c.ChangeId, c.InstanceId, i.Name AS InstanceName, c.DetectedAt, c.Area, c.Name, c.OldValue, c.NewValue
FROM insight.ConfigurationChanges c JOIN insight.Instances i ON i.InstanceId = c.InstanceId
WHERE (@instanceId IS NULL OR c.InstanceId = @instanceId) AND c.DetectedAt >= @from AND c.DetectedAt <= @to
ORDER BY c.DetectedAt DESC, c.ChangeId DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -1,63 +0,0 @@
using Dapper;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>Throughput and latency of one drive over time, for the charts.</summary>
public sealed record DiskSeries(string Volume, IReadOnlyList<MetricPoint> MbPerSecond, IReadOnlyList<MetricPoint> LatencyMs);
public sealed partial class InsightRepository
{
/// <summary>
/// I/O per drive in [from, to], from the 5-minute samples. Each sample's interval counts once per drive, so IOPS and
/// MB/s are averages over the time actually measured. Samples from before disk speed was collected have no drive.
/// </summary>
public async Task<IReadOnlyList<DiskActivity>> GetDiskActivityAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Volume, int DataFiles, int LogFiles, long Reads, long Writes, long BytesRead, long BytesWritten, long ReadStallMs, long WriteStallMs, int Seconds)>("""
SELECT Volume, MAX(DataFiles), MAX(LogFiles), SUM(Reads), SUM(Writes), SUM(BytesRead), SUM(BytesWritten), SUM(ReadStallMs), SUM(WriteStallMs), SUM(Seconds)
FROM (
SELECT Volume, SampledAt,
COUNT(CASE WHEN FileType <> 'LOG' THEN 1 END) AS DataFiles, COUNT(CASE WHEN FileType = 'LOG' THEN 1 END) AS LogFiles,
SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten,
SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, MAX(IntervalSeconds) AS Seconds
FROM insight.FileIoSamples
WHERE InstanceId = @instanceId AND SampledAt > @from AND SampledAt <= @to AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL
GROUP BY Volume, SampledAt
) AS samples
GROUP BY Volume ORDER BY Volume;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new DiskActivity(r.Volume, r.DataFiles, r.LogFiles, r.Reads, r.Writes, r.BytesRead, r.BytesWritten, r.ReadStallMs, r.WriteStallMs, r.Seconds)).ToList();
}
/// <summary>I/O per drive from the hourly history for the hours overlapping [from, to).</summary>
public async Task<IReadOnlyList<DiskActivity>> GetDiskActivityHourlyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Volume, long Reads, long Writes, long BytesRead, long BytesWritten, long ReadStallMs, long WriteStallMs, int Seconds)>("""
SELECT Volume, SUM(Reads), SUM(Writes), SUM(BytesRead), SUM(BytesWritten), SUM(ReadStallMs), SUM(WriteStallMs), SUM(Seconds)
FROM insight.DiskHourly WHERE InstanceId = @instanceId AND [Hour] >= @from AND [Hour] < @to
GROUP BY Volume ORDER BY Volume;
""", new { instanceId, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false);
return rows.Select(r => new DiskActivity(r.Volume, 0, 0, r.Reads, r.Writes, r.BytesRead, r.BytesWritten, r.ReadStallMs, r.WriteStallMs, r.Seconds)).ToList();
}
/// <summary>MB/s (read + write) and average latency per drive per sample since <paramref name="from"/>.</summary>
public async Task<IReadOnlyList<DiskSeries>> GetDiskSeriesAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string Volume, DateTime SampledAt, long Ios, long Bytes, long StallMs, int Seconds)>("""
SELECT Volume, SampledAt, SUM(Reads + Writes), SUM(BytesRead + BytesWritten), SUM(ReadStallMs + WriteStallMs), MAX(IntervalSeconds)
FROM insight.FileIoSamples
WHERE InstanceId = @instanceId AND SampledAt >= @from AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL
GROUP BY Volume, SampledAt ORDER BY Volume, SampledAt;
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
return rows.GroupBy(r => r.Volume)
.Select(g => new DiskSeries(
g.Key,
g.Where(r => r.Seconds > 0).Select(r => new MetricPoint(r.SampledAt, r.Bytes / 1048576.0 / r.Seconds)).ToList(),
g.Where(r => r.Ios > 0).Select(r => new MetricPoint(r.SampledAt, (double)r.StallMs / r.Ios)).ToList()))
.ToList();
}
}

View file

@ -1,97 +0,0 @@
using Dapper;
using SQLInsight.Core.Findings;
namespace SQLInsight.Repository;
public enum FindingStatus
{
Active,
Resolved,
All,
}
public enum FindingSort
{
/// <summary>Most severe first, then oldest.</summary>
Severity,
FirstSeen,
LastSeen,
Resolved,
}
/// <summary>
/// A search for findings. <see cref="From"/>/<see cref="To"/> select by when they arose (active), were resolved
/// (resolved), or either (all). <see cref="Category"/> is the middle part of the rule id (SEC in SQLI-SEC-001).
/// <see cref="Database"/> matches findings whose subject names the database.
/// </summary>
public sealed record FindingQuery
{
public FindingStatus Status { get; init; } = FindingStatus.Active;
public DateTimeOffset? From { get; init; }
public DateTimeOffset? To { get; init; }
public string? Instance { get; init; }
public string? Database { get; init; }
public string? RuleId { get; init; }
public Severity? MinSeverity { get; init; }
public string? Category { get; init; }
public FindingSort Sort { get; init; } = FindingSort.Severity;
public int Offset { get; init; }
public int Limit { get; init; } = 100;
}
public sealed partial class InsightRepository
{
public const int MaxFindingPage = 1000;
/// <summary>One page of findings matching <paramref name="query"/>, and whether there are more.</summary>
public async Task<(IReadOnlyList<StoredFinding> Findings, bool More)> SearchFindingsAsync(FindingQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var limit = Math.Clamp(query.Limit, 1, MaxFindingPage);
var status = query.Status switch
{
FindingStatus.Active => "f.ResolvedAt IS NULL",
FindingStatus.Resolved => "f.ResolvedAt IS NOT NULL",
_ => "1 = 1",
};
var period = query.Status switch
{
FindingStatus.Active => "(@from IS NULL OR f.FirstSeenAt >= @from) AND (@to IS NULL OR f.FirstSeenAt <= @to)",
FindingStatus.Resolved => "(@from IS NULL OR f.ResolvedAt >= @from) AND (@to IS NULL OR f.ResolvedAt <= @to)",
_ => "((@from IS NULL OR f.FirstSeenAt >= @from) AND (@to IS NULL OR f.FirstSeenAt <= @to) OR f.ResolvedAt >= @from AND (@to IS NULL OR f.ResolvedAt <= @to))",
};
var order = query.Sort switch
{
FindingSort.FirstSeen => "f.FirstSeenAt DESC, f.FindingId DESC",
FindingSort.LastSeen => "f.LastSeenAt DESC, f.FindingId DESC",
FindingSort.Resolved => "f.ResolvedAt DESC, f.FindingId DESC",
_ => "f.Severity DESC, f.FirstSeenAt, f.FindingId",
};
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = (await connection.QueryAsync<FindingRow>($"""
SELECT {FindingColumns}
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
WHERE {status} AND {period}
AND (@instance IS NULL OR i.Name = @instance)
AND (@ruleId IS NULL OR f.RuleId = @ruleId)
AND (@minSeverity IS NULL OR f.Severity >= @minSeverity)
AND (@category IS NULL OR f.RuleId LIKE N'%-' + @category + N'-%')
AND (@database IS NULL OR f.Subject = N'database: ' + @database OR f.Subject LIKE N'%' + @database + N'%')
ORDER BY {order}
OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY;
""", new
{
from = query.From?.UtcDateTime,
to = query.To?.UtcDateTime,
instance = query.Instance,
ruleId = query.RuleId,
minSeverity = query.MinSeverity is { } s ? (byte?)s : null,
category = query.Category,
database = query.Database,
offset = Math.Max(0, query.Offset),
take = limit + 1,
}).ConfigureAwait(false)).ToList();
return (rows.Take(limit).Select(Map).ToList(), rows.Count > limit);
}
}

View file

@ -53,24 +53,6 @@ public sealed partial class InsightRepository
) AS s ON t.InstanceId = s.InstanceId AND t.[Hour] = s.[Hour] AND t.WaitType = s.WaitType
WHEN MATCHED THEN UPDATE SET WaitingTasks = s.WaitingTasks, WaitTimeMs = s.WaitTimeMs, SignalWaitTimeMs = s.SignalWaitTimeMs
WHEN NOT MATCHED THEN INSERT (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs) VALUES (s.InstanceId, s.[Hour], s.WaitType, s.WaitingTasks, s.WaitTimeMs, s.SignalWaitTimeMs);
MERGE insight.DiskHourly AS t
USING (
SELECT InstanceId, [Hour], Volume, SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten,
SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, SUM(Seconds) AS Seconds
FROM (
SELECT InstanceId, DATEADD(hour, DATEDIFF(hour, 0, SampledAt), 0) AS [Hour], Volume, SampledAt,
SUM(Reads) AS Reads, SUM(Writes) AS Writes, SUM(BytesRead) AS BytesRead, SUM(BytesWritten) AS BytesWritten,
SUM(ReadStallMs) AS ReadStallMs, SUM(WriteStallMs) AS WriteStallMs, MAX(IntervalSeconds) AS Seconds
FROM insight.FileIoSamples WHERE SampledAt >= @from AND Volume IS NOT NULL AND IntervalSeconds IS NOT NULL
GROUP BY InstanceId, Volume, SampledAt
) AS samples
GROUP BY InstanceId, [Hour], Volume
) AS s ON t.InstanceId = s.InstanceId AND t.[Hour] = s.[Hour] AND t.Volume = s.Volume
WHEN MATCHED THEN UPDATE SET Reads = s.Reads, Writes = s.Writes, BytesRead = s.BytesRead, BytesWritten = s.BytesWritten,
ReadStallMs = s.ReadStallMs, WriteStallMs = s.WriteStallMs, Seconds = s.Seconds
WHEN NOT MATCHED THEN INSERT (InstanceId, [Hour], Volume, Reads, Writes, BytesRead, BytesWritten, ReadStallMs, WriteStallMs, Seconds)
VALUES (s.InstanceId, s.[Hour], s.Volume, s.Reads, s.Writes, s.BytesRead, s.BytesWritten, s.ReadStallMs, s.WriteStallMs, s.Seconds);
""", new { from }, commandTimeout: 600).ConfigureAwait(false);
var raw = utc.AddDays(-options.RawDays);
@ -82,17 +64,12 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ActiveWaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.LongOperations", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.DiskHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.PrivilegeChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false);
}

View file

@ -1,68 +0,0 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>A long operation as stored; <see cref="LoginName"/> is sensitive (local UI only).</summary>
public sealed record StoredLongOperation(int SessionId, DateTime StartedAt, string Command, string? DatabaseName, string? LoginName,
double PercentComplete, DateTime? EstimatedEnd, DateTime LastSeenAt)
{
/// <summary>Still running at the latest sample (seen within the last two minutes).</summary>
public bool IsRunning(DateTime utcNow) => utcNow - LastSeenAt < TimeSpan.FromMinutes(2);
public TimeSpan Duration => LastSeenAt - StartedAt;
}
public sealed partial class InsightRepository
{
private static async Task SaveLongOperationsAsync(SqlConnection connection, int instanceId, LongOperationSnapshot snapshot)
{
if (snapshot.Running.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
MERGE insight.LongOperations AS t
USING (SELECT * FROM OPENJSON(@json) WITH (SessionId int, StartedAt datetime2(0), Command nvarchar(32), DatabaseName nvarchar(128),
LoginName nvarchar(128), PercentComplete float, EstimatedEnd datetime2(0), LastSeenAt datetime2(0))) AS s
ON t.InstanceId = @instanceId AND t.StartedAt = s.StartedAt AND t.SessionId = s.SessionId AND t.Command = s.Command
WHEN MATCHED THEN UPDATE SET PercentComplete = s.PercentComplete, EstimatedEnd = s.EstimatedEnd, LastSeenAt = s.LastSeenAt
WHEN NOT MATCHED THEN INSERT (InstanceId, SessionId, StartedAt, Command, DatabaseName, LoginName, PercentComplete, EstimatedEnd, LastSeenAt)
VALUES (@instanceId, s.SessionId, s.StartedAt, s.Command, s.DatabaseName, s.LoginName, s.PercentComplete, s.EstimatedEnd, s.LastSeenAt);
""", new
{
instanceId,
json = JsonSerializer.Serialize(snapshot.Running.Select(o => new
{
o.SessionId,
StartedAt = Iso(o.StartedAt),
Command = o.Command.Length <= 32 ? o.Command : o.Command[..32],
o.DatabaseName,
o.LoginName,
o.PercentComplete,
EstimatedEnd = o.EstimatedEnd is { } end ? Iso(end) : null,
LastSeenAt = Iso(o.LastSeenAt),
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
}
/// <summary>Long operations that ran at some point in [from, to], newest first.</summary>
public async Task<IReadOnlyList<StoredLongOperation>> GetLongOperationsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 200,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredLongOperation>("""
SELECT TOP (@top) SessionId, StartedAt, Command, DatabaseName, LoginName, PercentComplete, EstimatedEnd, LastSeenAt
FROM insight.LongOperations
WHERE InstanceId = @instanceId AND StartedAt <= @to AND LastSeenAt >= @from
ORDER BY StartedAt DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -1,96 +0,0 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
public sealed record StoredPrivilegeChange(long ChangeId, int InstanceId, string InstanceName, DateTime DetectedAt, string Kind, string Privilege,
string Principal, string PrincipalType, string? ViaGroup, string? ChangedBy, DateTime? ChangedAt);
public sealed partial class InsightRepository
{
/// <summary>Privilege changes, newest first: for one instance (or all), detected in [from, to].</summary>
public async Task<IReadOnlyList<StoredPrivilegeChange>> GetPrivilegeChangesAsync(int? instanceId, DateTimeOffset from, DateTimeOffset to, int top = 1000, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredPrivilegeChange>("""
SELECT TOP (@top) c.ChangeId, c.InstanceId, i.Name AS InstanceName, c.DetectedAt, c.Kind, c.Privilege, c.Principal, c.PrincipalType,
c.ViaGroup, c.ChangedBy, c.ChangedAt
FROM insight.PrivilegeChanges c JOIN insight.Instances i ON i.InstanceId = c.InstanceId
WHERE (@instanceId IS NULL OR c.InstanceId = @instanceId) AND c.DetectedAt >= @from AND c.DetectedAt <= @to
ORDER BY c.DetectedAt DESC, c.ChangeId DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
private static async Task SavePrivilegeChangesAsync(SqlConnection connection, int instanceId, IReadOnlyList<PrivilegeChange> changes)
{
if (changes.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
INSERT insight.PrivilegeChanges (InstanceId, DetectedAt, Kind, Privilege, Principal, PrincipalType, ViaGroup, ChangedBy, ChangedAt)
SELECT @instanceId, DetectedAt, Kind, Privilege, Principal, PrincipalType, ViaGroup, ChangedBy, ChangedAt
FROM OPENJSON(@json) WITH (DetectedAt datetime2(0), Kind nvarchar(20), Privilege nvarchar(40), Principal nvarchar(260),
PrincipalType nvarchar(40), ViaGroup nvarchar(260), ChangedBy nvarchar(260), ChangedAt datetime2(0));
""", new
{
instanceId,
json = JsonSerializer.Serialize(changes.Select(c => new
{
DetectedAt = Iso(c.DetectedAt),
c.Kind,
c.Privilege,
Principal = Cut(c.Principal),
c.PrincipalType,
ViaGroup = c.ViaGroup is null ? null : Cut(c.ViaGroup),
ChangedBy = c.ChangedBy is null ? null : Cut(c.ChangedBy),
ChangedAt = c.ChangedAt is { } at ? Iso(at) : null,
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
static string Cut(string value) => value.Length <= 260 ? value : value[..260];
}
/// <summary>
/// Who and when, found after the change was stored (the default trace is written in buffers, so a change made just
/// before a collection may only be in it at the next): filled in where it was unknown.
/// </summary>
private static async Task SaveLatePrivilegeAuthorsAsync(SqlConnection connection, int instanceId, IReadOnlyList<PrivilegeChange> recent)
{
var known = recent.Where(c => c.ChangedBy is not null && c.ViaGroup is null).ToList();
if (known.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
UPDATE c SET ChangedBy = j.ChangedBy, ChangedAt = j.ChangedAt
FROM insight.PrivilegeChanges c
JOIN OPENJSON(@json) WITH (DetectedAt datetime2(0), Kind nvarchar(20), Privilege nvarchar(40), Principal nvarchar(260),
ChangedBy nvarchar(260), ChangedAt datetime2(0)) j
ON c.DetectedAt = j.DetectedAt AND c.Kind = j.Kind AND c.Privilege = j.Privilege AND c.Principal = j.Principal
WHERE c.InstanceId = @instanceId AND c.ChangedBy IS NULL AND c.ViaGroup IS NULL;
""", new
{
instanceId,
json = JsonSerializer.Serialize(known.Select(c => new
{
DetectedAt = Iso(c.DetectedAt),
c.Kind,
c.Privilege,
Principal = Cut(c.Principal),
ChangedBy = Cut(c.ChangedBy!),
ChangedAt = c.ChangedAt is { } at ? Iso(at) : null,
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
static string Cut(string value) => value.Length <= 260 ? value : value[..260];
}
}

View file

@ -28,9 +28,6 @@ public sealed record InstanceOverview
public int Info { get; init; }
public int NotMeasurable { get; init; }
/// <summary>Rules that apply but must be checked by hand on this version (RuleOutcome.ManualCheck).</summary>
public int ManualChecks { get; init; }
/// <summary>The most recent contact failed.</summary>
public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt);
@ -85,14 +82,13 @@ public sealed partial class InsightRepository
SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.EngineEdition, i.ConnectionProvider,
i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError,
ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info,
ISNULL(r.NotMeasurable, 0) AS NotMeasurable, ISNULL(r.ManualChecks, 0) AS ManualChecks
ISNULL(r.NotMeasurable, 0) AS NotMeasurable
FROM insight.Instances i
OUTER APPLY (SELECT SUM(CASE WHEN Severity = 4 THEN 1 ELSE 0 END) AS Critical, SUM(CASE WHEN Severity = 3 THEN 1 ELSE 0 END) AS High,
SUM(CASE WHEN Severity = 2 THEN 1 ELSE 0 END) AS Medium, SUM(CASE WHEN Severity = 1 THEN 1 ELSE 0 END) AS Low,
SUM(CASE WHEN Severity = 0 THEN 1 ELSE 0 END) AS Info
FROM insight.Findings WHERE InstanceId = i.InstanceId AND ResolvedAt IS NULL) f
OUTER APPLY (SELECT SUM(CASE WHEN Outcome IN (2, 3) THEN 1 ELSE 0 END) AS NotMeasurable, SUM(CASE WHEN Outcome = 5 THEN 1 ELSE 0 END) AS ManualChecks
FROM insight.RuleStates WHERE InstanceId = i.InstanceId) r
OUTER APPLY (SELECT COUNT(*) AS NotMeasurable FROM insight.RuleStates WHERE InstanceId = i.InstanceId AND Outcome IN (2, 3)) r
ORDER BY i.Name;
""").ConfigureAwait(false);
return rows.ToList();
@ -275,14 +271,3 @@ public sealed partial class InsightRepository
return rows.Select(r => new HourlyValue(r.Metric, DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc), r.Value)).ToList();
}
}
public sealed partial class InsightRepository
{
/// <summary>Whether any instance has a database recognised as Dynamics AX (the AX menu is shown only then).</summary>
public async Task<bool> HasDynamicsAxDatabasesAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = N'dynamics-ax' AND Json LIKE N'%\"database\"%';").ConfigureAwait(false) > 0;
}
}

View file

@ -35,15 +35,3 @@ public sealed partial class InsightRepository
""", new { from = Utc(from), to = Utc(to) }).ConfigureAwait(false);
}
}
public sealed partial class InsightRepository
{
/// <summary>Every rule id evaluated on some instance, with its title (local rules are only known this way).</summary>
public async Task<IReadOnlyList<(string RuleId, string Title)>> GetRuleTitlesAsync(CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string RuleId, string Title)>(
"SELECT RuleId, MAX(RuleTitle) AS Title FROM insight.RuleStates GROUP BY RuleId;").ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -9,9 +9,6 @@ public static class SupportCaseStatus
public const string Pending = "Pending";
public const string Open = "Open";
/// <summary>Closing was requested in SQLInsight Web; the agent has not had it confirmed by the hub yet.</summary>
public const string CloseRequested = "CloseRequested";
public const string Closed = "Closed";
/// <summary>The hub refused the request (e.g. no support in the license); see LastError.</summary>
@ -40,11 +37,6 @@ public sealed record SupportCaseRow
public DateTime CreatedAt { get; init; }
public DateTime? SentAt { get; init; }
public DateTime? ClosedAt { get; init; }
public DateTime? CloseRequestedAt { get; init; }
public string? CloseRequestedBy { get; init; }
public string? CloseComment { get; init; }
public int CloseAttempts { get; init; }
public string? ClosedBy { get; init; }
}
/// <summary>A request filed in SQLInsight Web.</summary>
@ -62,8 +54,7 @@ public sealed record NewSupportRequest(
string? Subject = null);
/// <summary>A case as the hub reports it.</summary>
public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId,
string? ClosedBy = null);
public sealed record HubSupportCase(string Reference, string Title, bool IsOpen, DateTime CreatedAt, DateTime? ClosedAt, string Source, Guid? ClientRequestId);
public sealed partial class InsightRepository
{
@ -111,8 +102,7 @@ public sealed partial class InsightRepository
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Reference = @reference, ClosedAt = @closedAt,
Status = CASE WHEN @isOpen = 0 THEN 'Closed' WHEN CloseRequestedAt IS NOT NULL THEN 'CloseRequested' ELSE 'Open' END,
SET Reference = @reference, Status = CASE WHEN @isOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @closedAt,
SentAt = @now, Attempts = Attempts + 1, LastError = NULL, UpdatedAt = @now
WHERE CaseId = @caseId;
""", new { caseId, reference, isOpen, closedAt, now = Utc(now) }).ConfigureAwait(false);
@ -142,15 +132,13 @@ public sealed partial class InsightRepository
{
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Reference = @Reference, ClosedAt = @ClosedAt, ClosedBy = COALESCE(@ClosedBy, ClosedBy),
-- A close request not yet confirmed must not be turned back into Open by an older list from the hub.
Status = CASE WHEN @IsOpen = 0 THEN 'Closed' WHEN Status = 'CloseRequested' THEN Status ELSE 'Open' END,
SET Reference = @Reference, Status = CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, ClosedAt = @ClosedAt,
Title = CASE WHEN ClientRequestId IS NULL THEN @Title ELSE Title END, LastError = NULL, UpdatedAt = @now
WHERE (@ClientRequestId IS NOT NULL AND ClientRequestId = @ClientRequestId) OR Reference = @Reference;
IF @@ROWCOUNT = 0
INSERT insight.SupportCases (ClientRequestId, Reference, Title, Source, Status, CreatedAt, ClosedAt, SentAt, UpdatedAt)
VALUES (@ClientRequestId, @Reference, @Title, @Source, CASE WHEN @IsOpen = 1 THEN 'Open' ELSE 'Closed' END, @CreatedAt, @ClosedAt, @now, @now);
""", new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, c.ClosedBy, now = Utc(now) }).ConfigureAwait(false);
""", new { c.Reference, c.Title, c.IsOpen, c.CreatedAt, c.ClosedAt, c.Source, c.ClientRequestId, now = Utc(now) }).ConfigureAwait(false);
}
}
}

View file

@ -1,120 +0,0 @@
using Dapper;
namespace SQLInsight.Repository;
public enum SupportCloseOutcome
{
NotFound,
/// <summary>Closed already: nothing to do.</summary>
AlreadyClosed,
/// <summary>Recorded; the agent sends it (at once, or once the hub has created the case).</summary>
Requested,
/// <summary>The hub refused the case; it cannot be closed.</summary>
Failed,
}
public sealed partial class InsightRepository
{
public async Task<SupportCaseRow?> GetSupportCaseAsync(long caseId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>("SELECT * FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId })
.ConfigureAwait(false);
}
public async Task<SupportCaseRow?> GetSupportCaseByRequestIdAsync(Guid clientRequestId, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<SupportCaseRow>(
"SELECT * FROM insight.SupportCases WHERE ClientRequestId = @clientRequestId;", new { clientRequestId }).ConfigureAwait(false);
}
/// <summary>The customer's cases, newest first: optionally by status and created in [from, to]; one page and whether there are more.</summary>
public async Task<(IReadOnlyList<SupportCaseRow> Cases, bool More)> SearchSupportCasesAsync(string? status, DateTimeOffset? from, DateTimeOffset? to,
int offset, int limit, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = (await connection.QueryAsync<SupportCaseRow>("""
SELECT * FROM insight.SupportCases
WHERE (@status IS NULL OR Status = @status) AND (@from IS NULL OR CreatedAt >= @from) AND (@to IS NULL OR CreatedAt <= @to)
ORDER BY CreatedAt DESC, CaseId DESC
OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY;
""", new { status, from = from?.UtcDateTime, to = to?.UtcDateTime, offset = Math.Max(0, offset), take = limit + 1 }).ConfigureAwait(false)).ToList();
return (rows.Take(limit).ToList(), rows.Count > limit);
}
/// <summary>
/// Records that the customer wants a case closed (SQLInsight Web: UPDATE on the close columns only, see
/// 0012_support_close.sql). An open case becomes CloseRequested; a pending one keeps its status and is closed once
/// the hub has created it.
/// </summary>
public async Task<SupportCloseOutcome> RequestSupportCloseAsync(long caseId, string? requestedBy, string? comment, DateTimeOffset now,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var status = await connection.QuerySingleOrDefaultAsync<string>("SELECT Status FROM insight.SupportCases WHERE CaseId = @caseId;", new { caseId })
.ConfigureAwait(false);
switch (status)
{
case null:
return SupportCloseOutcome.NotFound;
case SupportCaseStatus.Closed:
return SupportCloseOutcome.AlreadyClosed;
case SupportCaseStatus.Failed:
return SupportCloseOutcome.Failed;
}
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET CloseRequestedAt = COALESCE(CloseRequestedAt, @now), CloseRequestedBy = COALESCE(CloseRequestedBy, @requestedBy),
CloseComment = COALESCE(@comment, CloseComment), CloseNextAttemptAt = @now,
Status = CASE WHEN Status = 'Open' THEN 'CloseRequested' ELSE Status END, UpdatedAt = @now
WHERE CaseId = @caseId AND Status IN ('Pending', 'Open', 'CloseRequested');
""", new { caseId, requestedBy = Cut(requestedBy, 256), comment = Cut(comment, 1000), now = Utc(now) }).ConfigureAwait(false);
return SupportCloseOutcome.Requested;
static string? Cut(string? value, int max) => string.IsNullOrWhiteSpace(value) ? null : value.Trim().Length <= max ? value.Trim() : value.Trim()[..max];
}
/// <summary>Close requests the agent should send now: cases with a number whose closing is not confirmed.</summary>
public async Task<IReadOnlyList<SupportCaseRow>> GetDueSupportClosesAsync(DateTimeOffset now, int max = 10, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<SupportCaseRow>("""
SELECT TOP (@max) * FROM insight.SupportCases
WHERE Status = 'CloseRequested' AND Reference IS NOT NULL AND (CloseNextAttemptAt IS NULL OR CloseNextAttemptAt <= @now)
ORDER BY CloseRequestedAt;
""", new { max, now = Utc(now) }).ConfigureAwait(false);
return rows.ToList();
}
/// <summary>The hub confirmed the closing.</summary>
public async Task MarkSupportClosedAsync(long caseId, DateTime? closedAt, string? closedBy, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET Status = 'Closed', ClosedAt = COALESCE(@closedAt, @now), ClosedBy = @closedBy, CloseAttempts = CloseAttempts + 1, LastError = NULL, UpdatedAt = @now
WHERE CaseId = @caseId;
""", new { caseId, closedAt, closedBy, now = Utc(now) }).ConfigureAwait(false);
}
/// <summary>
/// A failed attempt to close. With <paramref name="nextAttemptAt"/> null the hub refused for good: the case is open
/// again with the reason in LastError.
/// </summary>
public async Task MarkSupportCloseFailedAsync(long caseId, string error, DateTimeOffset now, DateTimeOffset? nextAttemptAt, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("""
UPDATE insight.SupportCases
SET CloseAttempts = CloseAttempts + 1, LastError = LEFT(@error, 1000), UpdatedAt = @now, CloseNextAttemptAt = @next,
Status = CASE WHEN @next IS NULL THEN 'Open' ELSE Status END,
CloseRequestedAt = CASE WHEN @next IS NULL THEN NULL ELSE CloseRequestedAt END
WHERE CaseId = @caseId;
""", new { caseId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
}
}

View file

@ -1,93 +0,0 @@
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>A statement that was seen waiting on a wait type, summed over the sampled period.</summary>
/// <param name="Samples">Requests seen waiting (one per request per 15-second sample).</param>
/// <param name="Share">Its part of all samples of the wait type in the period.</param>
/// <param name="EstimatedWaitSeconds">Samples × 15 s: an estimate of the time spent waiting.</param>
/// <param name="AverageWaitMs">How long the requests had waited when sampled, on average.</param>
public sealed record WaitTypeStatement(string? QueryKey, string? DatabaseName, string? Text, long Samples, double Share,
double EstimatedWaitSeconds, double AverageWaitMs, long MaxWaitMs, long BlockedSamples);
/// <summary>A stored statement text (customer data, local UI only).</summary>
public sealed record StoredQueryText(string QueryKey, string? DatabaseName, string? QueryText, DateTime FirstSeenAt, DateTime LastSeenAt);
public sealed partial class InsightRepository
{
private static async Task SaveWaitSamplesAsync(SqlConnection connection, int instanceId, WaitSampleSnapshot snapshot)
{
if (snapshot.Rows.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
INSERT insight.ActiveWaitSamples (InstanceId, SampledAt, WaitType, DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions)
SELECT @instanceId, @at, WaitType, DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions
FROM OPENJSON(@rows) WITH (WaitType nvarchar(60), DatabaseName nvarchar(128), QueryKey varchar(200), Sessions int,
WaitTimeMs bigint, MaxWaitTimeMs bigint, BlockedSessions int);
MERGE insight.QueryTexts AS t
USING (SELECT QueryKey, MAX(DatabaseName) AS DatabaseName, MAX([Text]) AS [Text]
FROM OPENJSON(@statements) WITH (QueryKey varchar(200), DatabaseName nvarchar(128), [Text] nvarchar(4000)) GROUP BY QueryKey) AS s
ON t.InstanceId = @instanceId AND t.QueryKey = s.QueryKey
WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, QueryText = COALESCE(t.QueryText, s.[Text]), DatabaseName = COALESCE(t.DatabaseName, s.DatabaseName)
WHEN NOT MATCHED THEN INSERT (InstanceId, QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt) VALUES (@instanceId, s.QueryKey, s.DatabaseName, s.[Text], @at, @at);
""", new
{
instanceId,
at = Utc(snapshot.CapturedAt),
rows = JsonSerializer.Serialize(snapshot.Rows, Json),
statements = JsonSerializer.Serialize(snapshot.Statements, Json),
}).ConfigureAwait(false);
}
/// <summary>The statements seen waiting on a wait type in (from, to], most samples first.</summary>
public async Task<IReadOnlyList<WaitTypeStatement>> GetWaitTypeStatementsAsync(int instanceId, string waitType, DateTimeOffset from, DateTimeOffset to,
int top = 25, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<(string? QueryKey, string? DatabaseName, string? Text, long Samples, long WaitTimeMs, long MaxWaitMs, long Blocked, long Total)>("""
WITH s AS (
SELECT QueryKey, DatabaseName, SUM(CAST(Sessions AS bigint)) AS Samples, SUM(WaitTimeMs) AS WaitTimeMs,
MAX(MaxWaitTimeMs) AS MaxWaitMs, SUM(CAST(BlockedSessions AS bigint)) AS Blocked
FROM insight.ActiveWaitSamples
WHERE InstanceId = @instanceId AND WaitType = @waitType AND SampledAt > @from AND SampledAt <= @to
GROUP BY QueryKey, DatabaseName)
SELECT TOP (@top) s.QueryKey, COALESCE(s.DatabaseName, t.DatabaseName), t.QueryText, s.Samples, s.WaitTimeMs, s.MaxWaitMs, s.Blocked,
SUM(s.Samples) OVER ()
FROM s LEFT JOIN insight.QueryTexts t ON t.InstanceId = @instanceId AND t.QueryKey = s.QueryKey
ORDER BY s.Samples DESC, s.WaitTimeMs DESC;
""", new { instanceId, waitType, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.Select(r => new WaitTypeStatement(r.QueryKey, r.DatabaseName, r.Text, r.Samples, r.Total == 0 ? 0 : (double)r.Samples / r.Total,
r.Samples * WaitSampling.Interval.TotalSeconds, r.Samples == 0 ? 0 : (double)r.WaitTimeMs / r.Samples, r.MaxWaitMs, r.Blocked)).ToList();
}
/// <summary>
/// A wait type's wait time per minute over time: the 5-minute samples when the window is within the raw data, else
/// hourly values.
/// </summary>
public async Task<IReadOnlyList<MetricPoint>> GetWaitTypeSeriesAsync(int instanceId, string waitType, DateTimeOffset from, DateTimeOffset to,
bool hourly, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var sql = hourly
? "SELECT [Hour] AS At, CAST(WaitTimeMs AS float) / 60 AS Value FROM insight.WaitHourly WHERE InstanceId = @instanceId AND WaitType = @waitType AND [Hour] >= @hourFrom AND [Hour] < @to ORDER BY [Hour];"
: "SELECT SampledAt AS At, CAST(WaitTimeMs AS float) * 60 / IntervalSeconds AS Value FROM insight.WaitSamples WHERE InstanceId = @instanceId AND WaitType = @waitType AND IntervalSeconds > 0 AND SampledAt > @from AND SampledAt <= @to ORDER BY SampledAt;";
var rows = await connection.QueryAsync<MetricPoint>(sql, new { instanceId, waitType, from = from.UtcDateTime, hourFrom = FloorHour(from), to = to.UtcDateTime })
.ConfigureAwait(false);
return rows.ToList();
}
public async Task<StoredQueryText?> GetQueryTextAsync(int instanceId, string queryKey, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
return await connection.QuerySingleOrDefaultAsync<StoredQueryText>(
"SELECT QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt FROM insight.QueryTexts WHERE InstanceId = @instanceId AND QueryKey = @queryKey;",
new { instanceId, queryKey }).ConfigureAwait(false);
}
}

View file

@ -252,63 +252,15 @@ public sealed partial class InsightRepository(string connectionString)
case CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>:
// Never as a JSON snapshot: it holds statement texts.
return;
case InstanceConfigurationSnapshot configuration:
await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case WaitSampleSnapshot samples:
// Never as a JSON snapshot: it holds statement texts.
await SaveWaitSamplesAsync(connection, instanceId, samples).ConfigureAwait(false);
return;
case LongOperationSnapshot operations:
await SaveLongOperationsAsync(connection, instanceId, operations).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case PrivilegeSnapshot privileges:
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
default:
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
}
}
/// <summary>The latest result of a collector, as JSON: the agent reads it back after a restart, the web UI for display.</summary>
private static Task<int> SaveSnapshotJsonAsync(SqlConnection connection, int instanceId, string kind, object data) =>
connection.ExecuteAsync("""
await connection.ExecuteAsync("""
MERGE insight.Snapshots AS t
USING (SELECT @instanceId AS InstanceId, @kind AS Kind) AS s ON t.InstanceId = s.InstanceId AND t.Kind = s.Kind
WHEN MATCHED THEN UPDATE SET CapturedAt = SYSUTCDATETIME(), Json = @json
WHEN NOT MATCHED THEN INSERT (InstanceId, Kind, CapturedAt, Json) VALUES (@instanceId, @kind, SYSUTCDATETIME(), @json);
""", new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) });
private static async Task SaveConfigurationChangesAsync(SqlConnection connection, int instanceId, IReadOnlyList<ConfigurationChange> changes)
{
if (changes.Count == 0)
{
""", new { instanceId, kind, json = JsonSerializer.Serialize(data, data.GetType(), Json) }).ConfigureAwait(false);
return;
}
await connection.ExecuteAsync("""
INSERT insight.ConfigurationChanges (InstanceId, DetectedAt, Area, Name, OldValue, NewValue)
SELECT @instanceId, DetectedAt, Area, Name, OldValue, NewValue
FROM OPENJSON(@json) WITH (DetectedAt datetime2(0), Area nvarchar(40), Name nvarchar(260), OldValue nvarchar(400), NewValue nvarchar(400));
""", new
{
instanceId,
json = JsonSerializer.Serialize(changes.Select(c => new
{
DetectedAt = Utc(c.DetectedAt).ToString("yyyy-MM-ddTHH:mm:ss", System.Globalization.CultureInfo.InvariantCulture),
Area = Truncate(c.Area, 40),
Name = Truncate(c.Name, 260),
OldValue = c.OldValue is null ? null : Truncate(c.OldValue, 400),
NewValue = c.NewValue is null ? null : Truncate(c.NewValue, 400),
}), Json),
}).ConfigureAwait(false);
static string Truncate(string value, int length) => value.Length <= length ? value : value[..length];
}
private static Task<int> SaveWaitsAsync(SqlConnection connection, int instanceId, WaitStatsDelta delta) =>
@ -327,18 +279,12 @@ public sealed partial class InsightRepository(string connectionString)
private static Task<int> SaveFileIoAsync(SqlConnection connection, int instanceId, FileIoDelta delta) =>
connection.ExecuteAsync("""
INSERT insight.FileIoSamples (InstanceId, SampledAt, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten, Volume, IntervalSeconds)
SELECT @instanceId, @at, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten, Volume, @interval
INSERT insight.FileIoSamples (InstanceId, SampledAt, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten)
SELECT @instanceId, @at, DatabaseName, FileId, FileType, Reads, ReadStallMs, Writes, WriteStallMs, BytesRead, BytesWritten
FROM OPENJSON(@json) WITH (DatabaseName nvarchar(128), FileId int, FileType varchar(10), Reads bigint, ReadStallMs bigint,
Writes bigint, WriteStallMs bigint, BytesRead bigint, BytesWritten bigint, Volume nvarchar(260)) j
Writes bigint, WriteStallMs bigint, BytesRead bigint, BytesWritten bigint) j
WHERE NOT EXISTS (SELECT 1 FROM insight.FileIoSamples f WHERE f.InstanceId = @instanceId AND f.SampledAt = @at AND f.DatabaseName = j.DatabaseName AND f.FileId = j.FileId);
""", new
{
instanceId,
at = Utc(delta.To),
interval = (int)Math.Round((delta.To - delta.From).TotalSeconds),
json = JsonSerializer.Serialize(delta.Files.Select(f => f with { Volume = DiskVolumes.Of(f) }), Json),
});
""", new { instanceId, at = Utc(delta.To), json = JsonSerializer.Serialize(delta.Files, Json) });
private static Task<int> InsertMetricsAsync(SqlConnection connection, int instanceId, IEnumerable<(string Metric, DateTimeOffset At, double Value)> metrics) =>
connection.ExecuteAsync("""

View file

@ -1,21 +0,0 @@
/* Disk speed per drive: which volume each file sample belongs to, and how long the sample interval was (for IOPS and
MB/s). NULL on samples taken before this version. */
ALTER TABLE insight.FileIoSamples ADD Volume nvarchar(260) NULL, IntervalSeconds int NULL;
GO
/* Hourly I/O per drive, kept as long as the other hourly aggregates (Retention:HourlyDays). Seconds = the measured
time within the hour, so rates stay right when samples are missing. */
CREATE TABLE insight.DiskHourly
(
InstanceId int NOT NULL,
[Hour] datetime2(0) NOT NULL,
Volume nvarchar(260) NOT NULL,
Reads bigint NOT NULL,
Writes bigint NOT NULL,
BytesRead bigint NOT NULL,
BytesWritten bigint NOT NULL,
ReadStallMs bigint NOT NULL,
WriteStallMs bigint NOT NULL,
Seconds int NOT NULL,
CONSTRAINT PK_DiskHourly PRIMARY KEY (InstanceId, [Hour], Volume)
);

View file

@ -1,13 +0,0 @@
/* Configuration change log: sp_configure, trace flags, service accounts, version, tempdb files … as detected by the
instance-config collector (what changed, from/to, when it was seen). Kept as long as resolved findings. */
CREATE TABLE insight.ConfigurationChanges
(
ChangeId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ConfigurationChanges PRIMARY KEY,
InstanceId int NOT NULL CONSTRAINT FK_ConfigurationChanges_Instances REFERENCES insight.Instances (InstanceId),
DetectedAt datetime2(0) NOT NULL,
Area nvarchar(40) NOT NULL,
Name nvarchar(260) NOT NULL,
OldValue nvarchar(400) NULL,
NewValue nvarchar(400) NULL
);
CREATE INDEX IX_ConfigurationChanges_Instance ON insight.ConfigurationChanges (InstanceId, DetectedAt);

View file

@ -1,17 +0,0 @@
/* Privilege audit: who got or lost a fixed server role or CONTROL SERVER, directly or through a Windows group, when it
was detected, and (from the default trace, when readable) who did it and when. Names are customer data: they stay
in this repository and are never sent to SQLInsight Hub. Kept as long as resolved findings. */
CREATE TABLE insight.PrivilegeChanges
(
ChangeId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_PrivilegeChanges PRIMARY KEY,
InstanceId int NOT NULL CONSTRAINT FK_PrivilegeChanges_Instances REFERENCES insight.Instances (InstanceId),
DetectedAt datetime2(0) NOT NULL,
Kind nvarchar(20) NOT NULL,
Privilege nvarchar(40) NOT NULL,
Principal nvarchar(260) NOT NULL,
PrincipalType nvarchar(40) NOT NULL,
ViaGroup nvarchar(260) NULL,
ChangedBy nvarchar(260) NULL,
ChangedAt datetime2(0) NULL
);
CREATE INDEX IX_PrivilegeChanges_Instance ON insight.PrivilegeChanges (InstanceId, DetectedAt);

View file

@ -1,18 +0,0 @@
/* Active session sampling: what waiting user requests waited on every 15 seconds, by wait type, database and
statement (QueryKey as in insight.QueryTexts). Kept as long as the other raw samples (Retention:RawDays). */
CREATE TABLE insight.ActiveWaitSamples
(
InstanceId int NOT NULL,
SampledAt datetime2(0) NOT NULL,
WaitType nvarchar(60) NOT NULL,
DatabaseName nvarchar(128) NULL,
QueryKey varchar(200) NULL,
Sessions int NOT NULL,
WaitTimeMs bigint NOT NULL,
MaxWaitTimeMs bigint NOT NULL,
BlockedSessions int NOT NULL
);
CREATE CLUSTERED INDEX CX_ActiveWaitSamples ON insight.ActiveWaitSamples (InstanceId, SampledAt);
CREATE INDEX IX_ActiveWaitSamples_WaitType ON insight.ActiveWaitSamples (InstanceId, WaitType, SampledAt)
INCLUDE (DatabaseName, QueryKey, Sessions, WaitTimeMs, MaxWaitTimeMs, BlockedSessions);

View file

@ -1,15 +0,0 @@
/* Long operations with progress (BACKUP, RESTORE, DBCC, ...): one row per operation, updated while it runs.
LoginName is sensitive (local only). Kept as long as the other raw samples (Retention:RawDays). */
CREATE TABLE insight.LongOperations
(
InstanceId int NOT NULL,
SessionId int NOT NULL,
StartedAt datetime2(0) NOT NULL,
Command nvarchar(32) NOT NULL,
DatabaseName nvarchar(128) NULL,
LoginName nvarchar(128) NULL,
PercentComplete float NOT NULL,
EstimatedEnd datetime2(0) NULL,
LastSeenAt datetime2(0) NOT NULL,
CONSTRAINT PK_LongOperations PRIMARY KEY (InstanceId, StartedAt, SessionId, Command)
);

View file

@ -1,16 +0,0 @@
/* Closing support cases from SQLInsight Web (the /support page or POST /api/v1/support-cases/{id}/close): the web
UI records the request, the agent sends it to SQLInsight Hub and sets Closed when the hub has confirmed it. A request
on a case without a number yet is sent once the hub has created the case. */
ALTER TABLE insight.SupportCases ADD
CloseRequestedAt datetime2(0) NULL,
CloseRequestedBy nvarchar(256) NULL,
CloseComment nvarchar(1000) NULL,
CloseAttempts int NOT NULL CONSTRAINT DF_SupportCases_CloseAttempts DEFAULT (0),
CloseNextAttemptAt datetime2(0) NULL,
ClosedBy nvarchar(300) NULL;
GO
ALTER TABLE insight.SupportCases DROP CONSTRAINT CK_SupportCases_Status;
ALTER TABLE insight.SupportCases ADD CONSTRAINT CK_SupportCases_Status CHECK (Status IN ('Pending', 'Open', 'CloseRequested', 'Closed', 'Failed'));
GO
/* Like INSERT in 0005: the web UI may record a close request on these columns only. */
GRANT UPDATE ON insight.SupportCases (CloseRequestedAt, CloseRequestedBy, CloseComment, CloseNextAttemptAt, Status, UpdatedAt) TO public;

View file

@ -6,7 +6,6 @@ using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Indexes;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Rules.Performance;
using SQLInsight.Rules.Security;
namespace SQLInsight.Rules;
@ -31,9 +30,6 @@ public static class BuiltInRules
.. SimpleConfigurationRules.Create(),
new PendingConfigurationRule(),
new DeadlockTraceFlagRule(),
new ConfigurationChangedRule(),
new NewPrivilegeRule(),
new TooManySysadminsRule(),
// Databases
new DatabaseStateRule(),
@ -54,8 +50,6 @@ public static class BuiltInRules
new DataFileReadLatencyRule(),
new LogFileWriteLatencyRule(),
new CpuRule(),
new ConstantActivityRule(),
new SlowBackupRule(),
new MemoryGrantsPendingRule(),
new AnomalyRule(),
new PlanRegressionRule(),

View file

@ -96,10 +96,6 @@ public sealed class DeadlockRule : Rule
public override string Title => "Deadlocks";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SystemHealthSession];
public override string ManualCheck =>
"SQL Server 2005 har ingen system_health-session. Slå trace flag 1222 til (DBCC TRACEON (1222, -1) og -T1222 i opstartsparametrene), "
+ "så skrives hver deadlock i SQL Servers fejllog, og gennemgå fejlloggen jævnligt.";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);

View file

@ -400,45 +400,3 @@ public sealed class DeadlockTraceFlagRule : Rule
references: Docs.Learn("Deadlocks guide", "relational-databases/sql-server-deadlocks-guide")));
}
}
/// <summary>
/// A setting someone changed: sp_configure, a trace flag or a service account. Information, kept open for a day, so the
/// change is visible to whoever looks (and in the change log on the configuration page for good).
/// </summary>
public sealed class ConfigurationChangedRule : Rule
{
public override string Id => "SQLI-CFG-013";
public override string Title => "Konfiguration ændret";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<InstanceConfigurationSnapshot>();
return Result(snapshot.RecentChanges
.Where(c => ConfigurationFingerprint.IsNotable(c.Area))
.Select(c =>
{
var account = c.Area == ConfigurationFingerprint.ServiceAccount;
var what = c.Area == ConfigurationFingerprint.TraceFlag ? $"Trace flag {c.Name}" : c.Name;
var title = (c.OldValue, c.NewValue) switch
{
(null, _) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået til",
(_, null) when c.Area == ConfigurationFingerprint.TraceFlag => $"{what} er slået fra",
_ when account => $"Tjenestekontoen for {what} er ændret",
_ => $"{what} er ændret fra {c.OldValue ?? "–"} til {c.NewValue ?? "–"}",
};
return CreateFinding(
Severity.Info,
title,
"En indstilling på instansen er ændret. Det er ikke nødvendigvis et problem, men ændringer af serverindstillinger, "
+ "trace flags og tjenestekonti bør være planlagte og kendte, fordi de påvirker hele instansen.",
"Bekræft, at ændringen var planlagt, og at den er dokumenteret. Hele historikken står under Konfiguration på instansens side.",
[
new Evidence("Område", c.Area), new Evidence("Indstilling", c.Name),
new Evidence("Fra", c.OldValue ?? "–", Sensitive: account), new Evidence("Til", c.NewValue ?? "–", Sensitive: account),
new Evidence("Opdaget", c.DetectedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)),
],
subject: $"config: {c.Area}/{c.Name}/{c.DetectedAt.UtcDateTime:yyyyMMddHHmm}");
}));
}
}

View file

@ -250,10 +250,6 @@ public sealed class CheckDbRule : Rule
public override string Title => "DBCC CHECKDB er ikke kørt for nylig";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LastGoodCheckDbTime];
public override string ManualCheck =>
"Se seneste gode DBCC CHECKDB pr. database: kør som sysadmin DBCC DBINFO ('databasenavn') WITH TABLERESULTS og find feltet dbi_dbccLastKnownGood, "
+ "eller se historikken for vedligeholdelsesjobbet med CHECKDB i SQL Server Agent (msdb.dbo.sysjobhistory). Opgradér til SQL Server 2016 SP2 eller nyere, så måles det automatisk.";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
@ -327,10 +323,6 @@ public sealed class VirtualLogFileRule : Rule
public override string Title => "Transaktionsloggen har for mange VLF'er";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LogInfoDmv];
public override string ManualCheck =>
"Tæl VLF'er pr. database: kør DBCC LOGINFO i databasen (én række pr. VLF; kræver sysadmin). Over ca. 1.000 bør loggen krympes og vokse igen i store, faste trin. "
+ "Fra SQL Server 2016 SP2 måles det automatisk.";
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);

View file

@ -18,12 +18,5 @@ public interface IRule
/// </summary>
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
/// <summary>
/// How to check by hand what the rule measures, when the measurement needs a newer version or patch than the
/// instance has (missing capabilities that are about the measurement, not the feature). Null: the rule is simply
/// not relevant on such a version.
/// </summary>
string? ManualCheck => null;
RuleEvaluation Evaluate(RuleContext context);
}

View file

@ -11,7 +11,6 @@ public abstract class Rule : IRule
public abstract string Id { get; }
public abstract string Title { get; }
public virtual IReadOnlyCollection<Capability> RequiredCapabilities => [];
public virtual string? ManualCheck => null;
public abstract RuleEvaluation Evaluate(RuleContext context);

View file

@ -70,20 +70,6 @@ public sealed class RuleEngine
// Missing because of what the instance is (its version, or Azure) is not a gap in the monitoring.
var inherent = missing.All(InstanceCapabilities.IsInherent);
var release = context.Instance.Release.DisplayName();
// The topic applies, only the way of measuring it needs a newer version: someone has to check by hand.
if (inherent && rule.ManualCheck is { } manual && missing.All(InstanceCapabilities.IsMeasurement))
{
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = RuleOutcome.ManualCheck,
MissingCapabilities = missing,
Reason = $"Kan ikke måles på {release} – tjek manuelt (målingen kræver {InstanceCapabilities.Describe(missing)}). {manual}",
};
}
return new RuleResult
{
RuleId = rule.Id,

View file

@ -58,11 +58,4 @@ public enum RuleOutcome
/// Last in the enum: outcomes are stored as numbers.
/// </summary>
NotApplicable,
/// <summary>
/// The topic applies, but the way the rule measures it needs a newer version or patch (CHECKDB status before 2016
/// SP2 …): someone has to check it by hand (<see cref="IRule.ManualCheck"/>). Not a gap in the monitoring's permissions
/// or a failure, so not counted as not measurable. Stored as a number: keep last.
/// </summary>
ManualCheck,
}

View file

@ -1,92 +0,0 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Performance;
/// <summary>
/// A backup or restore that has run markedly longer than the same command on the same database usually takes: more
/// than twice the median of the last runs. Often the first sign of slow backup storage, a network share, or a
/// database that has grown.
/// </summary>
public sealed class SlowBackupRule : Rule
{
public const double Factor = 2;
public override string Id => "SQLI-OPS-001";
public override string Title => "Backup eller restore kører markant længere end normalt";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<LongOperationSnapshot>();
return Result(snapshot.Running
.Where(o => LongOperationHistory.IsBackupOrRestore(o.Command))
.Select(o => (Operation: o, Median: LongOperationHistory.Median(snapshot, o), Elapsed: o.Elapsed(snapshot.CapturedAt)))
.Where(x => x.Median is { } median && x.Elapsed > median * Factor && x.Elapsed - median >= TimeSpan.FromMinutes(5))
.Select(x => CreateFinding(
Severity.Medium,
$"{x.Operation.Command} af {x.Operation.DatabaseName} har kørt {Format.Age(x.Elapsed)} (normalt {Format.Age(x.Median!.Value)})",
$"{x.Operation.Command} af {x.Operation.DatabaseName} har kørt mere end dobbelt så længe som de seneste kørsler (median {Format.Age(x.Median.Value)}). "
+ "Det kan være langsom backup-storage eller netværk, en database der er vokset, eller en backup der hænger og blokerer den næste.",
"Se ventetypen for sessionen (BACKUPIO/ASYNC_IO_COMPLETION = backupmålet er langsomt), og tjek backupmålet og netværket. "
+ "Følg med i forventet sluttid under Langvarige operationer på instansens side.",
[
new Evidence("Kommando", x.Operation.Command),
new Evidence("Database", x.Operation.DatabaseName ?? "–"),
new Evidence("Startet", x.Operation.StartedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)),
new Evidence("Færdig", $"{Format.Number(x.Operation.PercentComplete, 1)} %"),
new Evidence("Forventet slut", x.Operation.EstimatedEnd?.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) ?? "ukendt"),
new Evidence("Median af seneste kørsler", Format.Age(x.Median.Value)),
new Evidence("Login", x.Operation.LoginName ?? "–", Sensitive: true),
],
subject: $"operation: {x.Operation.Command}/{x.Operation.DatabaseName}")));
}
}
/// <summary>
/// A statement that in practice keeps a CPU core busy all the time: its CPU time is more than the threshold
/// (SQLInsight:TopSql:ActivityThresholdPercent, default 50 %) of the time it has been in the plan cache, for at least an
/// hour. SQLInsight never clears the plan cache or changes anything; the finding says where to look.
/// </summary>
public sealed class ConstantActivityRule : Rule
{
public static readonly TimeSpan MinimumLifetime = TimeSpan.FromHours(1);
public override string Id => "SQLI-CPU-002";
public override string Title => "Sætning kører næsten uafbrudt";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>().Snapshot;
var threshold = snapshot.ActivityThresholdPercent;
return Result(snapshot.Queries
.Where(q => q.LastExecutionAt >= snapshot.ServerTime.AddMinutes(-15) && q.LastExecutionAt - q.CreatedAt >= MinimumLifetime
&& q.ActivityPercent > threshold)
.OrderByDescending(q => q.ActivityPercent)
.Take(5)
.Select(q => CreateFinding(
Severity.Medium,
$"En sætning har brugt {Format.Number(q.ActivityPercent!.Value)} % af en CPU-kerne i {Format.Age(q.LastExecutionAt - q.CreatedAt)}",
"Sætningens samlede CPU-tid er mere end " + Format.Number(threshold) + " % af den tid, den har ligget i plan cache. "
+ "Den bruger i praksis én CPU-kerne konstant (over 100 % betyder flere kerner). Det er typisk en sætning, der kaldes i en løkke "
+ "fra en applikation eller et job, eller en tung forespørgsel, der kører hele tiden.",
"Find sætningen under tungeste forespørgsler i analysen (sorteret efter CPU), og find ud af, hvem der kalder den så ofte. "
+ "Se planen for manglende indexes. SQLInsight ændrer aldrig noget: tøm ikke plan cache for at få fundet væk – det skjuler kun tallet.",
[
new Evidence("Aktivitet", $"{Format.Number(q.ActivityPercent.Value)} % af en kerne"),
new Evidence("I plan cache", Format.Age(q.LastExecutionAt - q.CreatedAt)),
new Evidence("CPU i alt", $"{Format.Number(q.WorkerTimeUs / 1_000_000.0)} s"),
new Evidence("Kørsler", Format.Number(q.Executions)),
new Evidence("Database", q.DatabaseName ?? "–"),
new Evidence("Sætning", q.Text is { Length: > 500 } t ? t[..500] + " …" : q.Text ?? "–", Sensitive: true),
],
subject: $"query: {q.Key}")));
}
}

View file

@ -125,10 +125,6 @@ public sealed class SpillRule : PlanRule
public override string Title => "Sorteringer og hash joins spilder til tempdb";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStatsMemoryColumns];
public override string ManualCheck =>
"Se efter Sort Warnings og Hash Warnings i de faktiske eksekveringsplaner for de tunge forespørgsler (SSMS: Include Actual Execution Plan), "
+ "eller lyt efter hændelserne Sort Warnings/Hash Warning med SQL Profiler i en kort periode. Fra SQL Server 2016 SP2/2017 CU3 måles det automatisk.";
protected override Finding? Check(RuleContext context, AnalyzedStatement s)
{
if (s.TotalSpills is not { } spills || s.Executions == 0 || (double)spills / s.Executions < MinSpillPagesPerExecution)
@ -160,10 +156,6 @@ public sealed class ExcessiveGrantRule : PlanRule
public override string Title => "Forespørgsel får langt mere hukommelse, end den bruger";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStatsMemoryColumns];
public override string ManualCheck =>
"Se de aktuelle memory grants under belastning: SELECT * FROM sys.dm_exec_query_memory_grants (requested_memory_kb mod used_memory_kb og max_used_memory_kb). "
+ "Fra SQL Server 2016 SP2/2017 CU3 måles det automatisk.";
protected override Finding? Check(RuleContext context, AnalyzedStatement s)
{
if (s.TotalGrantKb is not { } granted || s.TotalUsedGrantKb is not { } used || s.Executions == 0)

View file

@ -14,97 +14,65 @@ internal static class FileIo
}
/// <summary>
/// Slow reads from the data files on a drive. Per drive rather than per file: when storage is slow, every file on it
/// is, and one finding per drive says so once. The slowest files are in the evidence.
/// Slow reads from data files.
/// </summary>
public sealed class DataFileReadLatencyRule : Rule
{
public const int MinReads = 1000;
public override string Id => "SQLI-IO-001";
public override string Title => "Høj læselatens på drev med datafiler";
public override string Title => "Høj læselatens på datafiler";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var delta = FileIo.Delta(context);
var seconds = (delta.To - delta.From).TotalSeconds;
return Result(delta.Files
.Where(f => f.FileType == "ROWS")
.GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase)
.Select(g => (Files: g.ToList(), Disk: DiskActivity.ByVolume(g, seconds)[0]))
.Where(d => d.Disk.Reads >= MinReads && d.Disk.AverageReadMs >= 20)
.Select(d => CreateFinding(
d.Disk.AverageReadMs >= 50 ? Severity.High : Severity.Medium,
$"{d.Disk.Volume} (datafiler): {Format.Number(d.Disk.AverageReadMs!.Value, 1)} ms i gennemsnit pr. læsning",
"Læsninger fra datafilerne på drevet tager i gennemsnit over 20 ms. På moderne storage (SSD/SAN) bør de ligge under "
+ "5-10 ms. Langsomme læsninger rammer direkte alle forespørgsler, der ikke finder data i hukommelsen (buffer pool), "
+ "så brugerne oplever langsomme skærmbilleder og rapporter.",
.Where(f => f.FileType == "ROWS" && f.Reads >= MinReads && f.AverageReadLatencyMs >= 20)
.Select(f => CreateFinding(
f.AverageReadLatencyMs >= 50 ? Severity.High : Severity.Medium,
$"{f.DatabaseName}: {Format.Number(f.AverageReadLatencyMs!.Value, 1)} ms i gennemsnit pr. læsning",
"Læsninger fra datafilen tager i gennemsnit over 20 ms. På moderne storage (SSD/SAN) bør de ligge under 5-10 ms. "
+ "Langsomme læsninger rammer direkte alle forespørgsler, der ikke finder data i buffer pool.",
"Sammenlign med latensen i storage-laget (Windows' Avg. Disk sec/Read, SAN-statistik) for at se, om ventetiden "
+ "opstår i storage eller undervejs (HBA, multipath, VM-host). Ser storage-laget hurtigt ud, er drevet overbelastet "
+ "af antallet af læsninger: se læse-IOPS og MB/s, og undersøg om de kan reduceres med bedre indexes eller mere hukommelse.",
Evidence(d.Disk, d.Files, read: true, seconds),
subject: $"volume: {d.Disk.Volume} data",
+ "opstår i storage eller undervejs (HBA, multipath, VM-host). Undersøg også, om mængden af læsninger kan reduceres "
+ "med bedre indexes eller mere hukommelse.",
[new Evidence("Fil", f.PhysicalName), new Evidence("Læsninger", Format.Number(f.Reads)),
new Evidence("Gns. latens", $"{Format.Number(f.AverageReadLatencyMs!.Value, 1)} ms"),
new Evidence("Interval", $"{Format.Number((delta.To - delta.From).TotalMinutes)} min")],
subject: $"file: {f.DatabaseName}/{f.FileId}",
references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql"))));
}
/// <summary>Drive totals plus the three slowest files, shared with the log write rule.</summary>
internal static Evidence[] Evidence(DiskActivity disk, IReadOnlyList<FileIoStat> files, bool read, double seconds)
{
var slowest = files
.Where(f => read ? f.Reads > 0 : f.Writes > 0)
.OrderByDescending(f => read ? f.AverageReadLatencyMs : f.AverageWriteLatencyMs)
.Take(3)
.Select(f => $"{f.DatabaseName} {Format.Number((read ? f.AverageReadLatencyMs : f.AverageWriteLatencyMs)!.Value, 1)} ms ({f.PhysicalName})");
return read
?
[
new("Drev", disk.Volume), new("Læsninger", Format.Number(disk.Reads)),
new("Gns. latens", $"{Format.Number(disk.AverageReadMs!.Value, 1)} ms"),
new("Læse-IOPS", Format.Number(disk.ReadIops ?? 0, 1)), new("Læst", $"{Format.Number(disk.ReadMbPerSecond ?? 0, 1)} MB/s"),
new("Langsomste filer", string.Join("; ", slowest)), new("Interval", $"{Format.Number(seconds / 60)} min"),
]
:
[
new("Drev", disk.Volume), new("Skrivninger", Format.Number(disk.Writes)),
new("Gns. latens", $"{Format.Number(disk.AverageWriteMs!.Value, 1)} ms"),
new("Skrive-IOPS", Format.Number(disk.WriteIops ?? 0, 1)), new("Skrevet", $"{Format.Number(disk.WriteMbPerSecond ?? 0, 1)} MB/s"),
new("Langsomste filer", string.Join("; ", slowest)), new("Interval", $"{Format.Number(seconds / 60)} min"),
];
}
}
/// <summary>
/// Slow writes to the transaction log files on a drive (per drive, like <see cref="DataFileReadLatencyRule"/>).
/// Slow writes to transaction log files.
/// </summary>
public sealed class LogFileWriteLatencyRule : Rule
{
public const int MinWrites = 1000;
public override string Id => "SQLI-IO-002";
public override string Title => "Høj skrivelatens på drev med logfiler";
public override string Title => "Høj skrivelatens på logfiler";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var delta = FileIo.Delta(context);
var seconds = (delta.To - delta.From).TotalSeconds;
return Result(delta.Files
.Where(f => f.FileType == "LOG")
.GroupBy(DiskVolumes.Of, StringComparer.OrdinalIgnoreCase)
.Select(g => (Files: g.ToList(), Disk: DiskActivity.ByVolume(g, seconds)[0]))
.Where(d => d.Disk.Writes >= MinWrites && d.Disk.AverageWriteMs >= 5)
.Select(d => CreateFinding(
d.Disk.AverageWriteMs >= 15 ? Severity.High : Severity.Medium,
$"{d.Disk.Volume} (logfiler): {Format.Number(d.Disk.AverageWriteMs!.Value, 1)} ms i gennemsnit pr. logskrivning",
.Where(f => f.FileType == "LOG" && f.Writes >= MinWrites && f.AverageWriteLatencyMs >= 5)
.Select(f => CreateFinding(
f.AverageWriteLatencyMs >= 15 ? Severity.High : Severity.Medium,
$"{f.DatabaseName}: {Format.Number(f.AverageWriteLatencyMs!.Value, 1)} ms i gennemsnit pr. logskrivning",
"Hver commit venter på, at loggen er skrevet. Logskrivninger bør tage 1-2 ms; over 5 ms gør alle skrivende "
+ "transaktioner mærkbart langsommere, fx når brugerne bogfører eller gemmer.",
"Placér logfilerne på storage med lav skrivelatens og write cache med batteribackup, adskilt fra datafilerne. "
+ "Tjek, om andre filer eller VM'er deler samme drev, og om skrive-MB/s nærmer sig det, drevet kan levere.",
DataFileReadLatencyRule.Evidence(d.Disk, d.Files, read: false, seconds),
subject: $"volume: {d.Disk.Volume} log",
+ "transaktioner mærkbart langsommere.",
"Placér logfilen på storage med lav skrivelatens og write cache med batteribackup, adskilt fra datafilerne. "
+ "Tjek, om andre filer eller VM'er deler samme volumen.",
[new Evidence("Fil", f.PhysicalName), new Evidence("Skrivninger", Format.Number(f.Writes)),
new Evidence("Gns. latens", $"{Format.Number(f.AverageWriteLatencyMs!.Value, 1)} ms")],
subject: $"file: {f.DatabaseName}/{f.FileId}",
references: Docs.Learn("sys.dm_io_virtual_file_stats", "relational-databases/system-dynamic-management-views/sys-dm-io-virtual-file-stats-transact-sql"))));
}
}

View file

@ -97,29 +97,41 @@ public static class WaitRules
new DominantWaitRule(
"SQLI-WAIT-003", WaitCategory.Locks, 0.2, 0.5, 5,
"Sessioner venter meget på låse",
WaitKnowledge.Locks.Meaning,
WaitKnowledge.Locks.Actions,
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
+ "læsninger, der blokeres af skrivninger.",
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
Docs.Learn("Transaction locking guide", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")),
new DominantWaitRule(
"SQLI-WAIT-004", WaitCategory.BufferIo, 0.3, 1, 8,
"Forespørgsler venter meget på at læse data fra disk",
WaitKnowledge.BufferIo.Meaning,
WaitKnowledge.BufferIo.Actions,
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
+ "Tjek derefter, om max server memory er sat fornuftigt.",
WaitStatsDocs),
new DominantWaitRule(
"SQLI-WAIT-006", WaitCategory.Parallelism, 0.3, 2, 16,
"Parallelle forespørgsler venter meget på hinanden",
WaitKnowledge.Parallelism.Meaning,
WaitKnowledge.Parallelism.Actions,
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
WaitStatsDocs),
new DominantWaitRule(
"SQLI-WAIT-007", WaitCategory.PageLatch, 0.2, 0.5, 4,
"Contention på sider i hukommelsen (PAGELATCH)",
WaitKnowledge.PageLatch.Meaning,
WaitKnowledge.PageLatch.Actions,
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")),
];
}

View file

@ -1,109 +0,0 @@
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Security;
/// <summary>
/// Someone got sysadmin (or another elevated server right): directly or because they were added to a Windows group that
/// has it. High for rights that give full control, kept open for a week so it is seen. Names are sensitive evidence;
/// title and subject carry no names, so nothing identifying reaches SQLInsight Hub.
/// </summary>
public sealed class NewPrivilegeRule : Rule
{
public override string Id => "SQLI-SEC-001";
public override string Title => "Ny forhøjet rettighed på serveren";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<PrivilegeSnapshot>();
return Result(snapshot.RecentChanges
.Where(c => c.Kind == PrivilegeAnalysis.Added)
.Select(c =>
{
var highRisk = PrivilegeAnalysis.IsHighRisk(c.Privilege);
var how = c.ViaGroup is null ? c.PrincipalType : "Windows-bruger via gruppe";
var evidence = new List<Evidence>
{
new("Rettighed", c.Privilege),
new("Hvem fik den", c.Principal, Sensitive: true),
new("Type", how),
};
if (c.ViaGroup is not null)
{
evidence.Add(new("Via Windows-gruppe", c.ViaGroup, Sensitive: true));
}
evidence.Add(new("Ændret af", c.ChangedBy ?? (c.ViaGroup is not null ? "ændret i Active Directory" : "ukendt"), Sensitive: c.ChangedBy is not null));
evidence.Add(new("Ændret", c.ChangedAt is { } at ? Utc(at) : "ukendt"));
evidence.Add(new("Opdaget", Utc(c.DetectedAt)));
if (c.ChangedBy is null && c.ViaGroup is null && snapshot.TraceStatus is { } why)
{
evidence.Add(new("Hvorfor ukendt", why));
}
return CreateFinding(
highRisk ? Severity.High : Severity.Medium,
$"Ny {c.Privilege}-rettighed ({how})",
highRisk
? $"En konto har fået {c.Privilege}. Det giver fuld kontrol over SQL Server: alle data i alle databaser, alle indstillinger og "
+ "muligheden for at give andre rettigheder. Nye medlemmer af denne gruppe skal kunne forklares, fx over for revisionen."
: $"En konto har fået {c.Privilege}, en forhøjet serverrettighed. Den giver ikke fuld kontrol, men mere end almindelige brugere bør have.",
"Bekræft, at ændringen var godkendt, og hvem der står bag (se målingerne). Var den ikke det, så fjern rettigheden med det samme "
+ $"(ALTER SERVER ROLE [{c.Privilege}] DROP MEMBER …, eller fjern brugeren fra Windows-gruppen), og find ud af, hvordan den blev givet. "
+ "Hele historikken står under Rettigheder på instansens side og i revisionsrapporten.",
evidence,
subject: $"privilege: {c.Privilege}/{PrivilegeAnalysis.Fingerprint(c.Principal + "|" + c.ViaGroup)}/{c.DetectedAt.UtcDateTime:yyyyMMddHHmm}");
}));
}
private static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
}
/// <summary>
/// More people and accounts with sysadmin than the customer's own limit (SQLInsight:Privileges:MaxSysadminAccounts,
/// default 3). Windows groups count as the people in them.
/// </summary>
public sealed class TooManySysadminsRule : Rule
{
public override string Id => "SQLI-SEC-002";
public override string Title => "For mange personer og konti med sysadmin";
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<PrivilegeSnapshot>();
var accounts = PrivilegeAnalysis.Accounts(snapshot, PrivilegeAnalysis.Sysadmin);
var unknownGroups = PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin);
if (accounts.Count <= snapshot.MaxSysadminAccounts)
{
return RuleEvaluation.Passed();
}
var evidence = new List<Evidence>
{
new("Antal", accounts.Count.ToString(CultureInfo.InvariantCulture)),
new("Grænse", snapshot.MaxSysadminAccounts.ToString(CultureInfo.InvariantCulture)),
new("Personer og konti", string.Join(", ", accounts.Select(a => a.Source == PrivilegeAnalysis.Direct ? a.Account : $"{a.Account} (via {a.Source})")), Sensitive: true),
};
if (unknownGroups.Count > 0)
{
evidence.Add(new("Grupper der ikke kunne foldes ud", string.Join(", ", unknownGroups.Select(g => g.Name)), Sensitive: true));
}
return RuleEvaluation.Found(CreateFinding(
Severity.Medium,
$"{accounts.Count} personer og konti har sysadmin (grænse {snapshot.MaxSysadminAccounts})",
"Alle med sysadmin kan læse og ændre alle data og alle indstillinger, og deres fejl eller kompromitterede konti rammer hele instansen. "
+ "Jo flere der har den, jo sværere er det at styre, og revisionen vil typisk spørge til det. Windows-grupper tæller med de personer, de indeholder.",
"Gennemgå listen (under Rettigheder på instansens side), og fjern sysadmin fra dem, der ikke har brug for den. Giv i stedet de "
+ "rettigheder, opgaven kræver (fx db_owner på én database eller VIEW SERVER STATE). Grænsen sættes med SQLInsight:Privileges:MaxSysadminAccounts.",
evidence,
subject: "privilege: sysadmin count"));
}
}

View file

@ -7,55 +7,15 @@ using Microsoft.Extensions.Options;
namespace SQLInsight.Web.Api;
/// <summary>A configured API token: only its SHA-256, and what it may do.</summary>
public sealed record ApiToken(string Name, string Hash, IReadOnlyList<string> Scopes)
{
/// <summary>Read everything under /api/ and /metrics. The only scope of tokens in the old format.</summary>
public const string Read = "read";
/// <summary>Create and close support cases.</summary>
public const string SupportWrite = "support:write";
public static readonly IReadOnlyList<string> KnownScopes = [Read, SupportWrite];
/// <summary>
/// SQLInsight:Web:ApiTokens: either the old form <c>"name": "sha256:…"</c> (read only) or
/// <c>"name": { "hash": "sha256:…", "scopes": [ "read", "support:write" ] }</c>.
/// </summary>
public static IReadOnlyList<ApiToken> From(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
var tokens = new List<ApiToken>();
foreach (var entry in configuration.GetSection(WebOptions.SectionName + ":ApiTokens").GetChildren())
{
if (entry.Value is { Length: > 0 } hash)
{
tokens.Add(new ApiToken(entry.Key, Normalize(hash), [Read]));
}
else if (entry["hash"] is { Length: > 0 } configured)
{
var scopes = entry.GetSection("scopes").GetChildren().Select(s => s.Value).OfType<string>().ToList();
tokens.Add(new ApiToken(entry.Key, Normalize(configured), scopes.Count == 0 ? [Read] : scopes));
}
}
return tokens;
}
private static string Normalize(string hash) => (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? hash["sha256:".Length..] : hash).ToUpperInvariant();
}
/// <summary>
/// Bearer tokens for machines (Prometheus, Grafana, SCOM scripts, ITSM) that cannot log in with Windows authentication.
/// Only the SHA-256 of each token is configured, and a token only opens /api/ and /metrics, never the web pages. What
/// a token may do beyond reading is given by its scopes.
/// Bearer tokens for machines (Prometheus, Grafana, SCOM scripts) that cannot log in with Windows authentication.
/// Only the SHA-256 of each token is configured, and a token only opens /api/ and /metrics, never the web pages.
/// </summary>
public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IConfiguration configuration)
public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IOptions<WebOptions> web)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "ApiToken";
public const string ClaimType = "sqlinsight:api-token";
public const string ScopeClaimType = "sqlinsight:scope";
public static bool AppliesTo(PathString path) => path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase);
@ -73,13 +33,12 @@ public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions>
}
var hash = Hash(header["Bearer ".Length..].Trim());
foreach (var token in ApiToken.From(configuration))
foreach (var (name, expected) in web.Value.ApiTokens)
{
if (token.Hash.Length == hash.Length && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(token.Hash), Encoding.ASCII.GetBytes(hash)))
var configured = expected.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? expected["sha256:".Length..] : expected;
if (configured.Length == hash.Length && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(configured.ToUpperInvariant()), Encoding.ASCII.GetBytes(hash)))
{
var claims = new List<Claim> { new(ClaimTypes.Name, "token:" + token.Name), new(ClaimType, token.Name) };
claims.AddRange(token.Scopes.Select(s => new Claim(ScopeClaimType, s)));
var identity = new ClaimsIdentity(claims, SchemeName);
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "api:" + name), new Claim(ClaimType, name)], SchemeName);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)));
}
}
@ -87,16 +46,6 @@ public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions>
return Task.FromResult(AuthenticateResult.Fail("Ukendt API-token."));
}
/// <summary>
/// Whether the caller may use a scope: a token needs it among its scopes; a Windows user with access to the web UI
/// (or anyone when authentication is off in development) may do what the UI lets them.
/// </summary>
public static bool HasScope(ClaimsPrincipal user, string scope)
{
ArgumentNullException.ThrowIfNull(user);
return !user.HasClaim(c => c.Type == ClaimType) || user.HasClaim(ScopeClaimType, scope);
}
public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
/// <summary>A new random token and the line to put in appsettings.json.</summary>
@ -106,14 +55,3 @@ public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions>
return (token, "sha256:" + Hash(token));
}
}
public static class ApiScopes
{
/// <summary>Reading needs the scope "read" for tokens (every token in the old format has it).</summary>
public static TBuilder RequireRead<TBuilder>(this TBuilder builder)
where TBuilder : IEndpointConventionBuilder =>
builder.AddEndpointFilter(async (context, next) =>
HttpMethods.IsGet(context.HttpContext.Request.Method) && !ApiTokenHandler.HasScope(context.HttpContext.User, ApiToken.Read)
? Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Tokenet må ikke læse", detail: "API-tokenet mangler scope \"read\".")
: await next(context).ConfigureAwait(false));
}

View file

@ -1,525 +0,0 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Reports;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>
/// The read-only JSON API for findings, rules, reports and the pages of the web UI: the same data, windows and filters
/// as the pages, built from the same models. Customer data (statement texts, logins, names) is sensitive and returned
/// as "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
/// </summary>
public static class ApiV1Endpoints
{
public const string Redacted = "[udeladt]";
private static readonly string[] ReportFormats = ["json", "pdf"];
/// <summary>Report types under /api/v1/reports/{type}.</summary>
public static readonly IReadOnlyList<(string Type, string Title, string Parameters)> ReportTypes =
[
("driftsrapport", "Driftsrapport til ledelse og compliance", "days (1–366, standard 30)"),
("analyse", "Analyse af tidsrum for én instans", "instance (påkrævet), from, to (ISO 8601; standard seneste time), sort (cpu|varighed|laesninger|koersler)"),
("rettigheder", "Rettighedsrevision", "days (1–366, standard 90), instance (valgfri; standard alle)"),
];
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
api.MapGet("/findings", SearchFindingsAsync)
.WithSummary("Fund: aktive, løste eller alle, med filtre og sider / Findings with filters and paging")
.WithDescription("Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=.");
api.MapGet("/findings/{id:long}", FindingAsync).WithSummary("Ét fund med forklaring, anbefaling, målinger, fix-script og historik / One finding");
api.MapGet("/rules", RulesAsync).WithSummary("Regler: indbyggede, community og lokale / Rules");
api.MapGet("/instances/{name}/rule-states", RuleStatesAsync).WithSummary("Reglernes udfald på en instans / Rule outcomes on an instance");
api.MapGet("/reports", () => ReportTypes.Select(r => new { r.Type, r.Title, r.Parameters, Formats = ReportFormats }))
.WithSummary("Rapporttyper og deres parametre / Report types");
api.MapGet("/reports/{type}", ReportAsync).WithSummary("En rapport som JSON eller PDF (format=json|pdf) / A report as JSON or PDF");
api.MapGet("/instances/{name}/analysis", AnalysisAsync).WithSummary("Analyse af tidsrum / Time window analysis");
api.MapGet("/instances/{name}/top-sql", TopSqlAsync).WithSummary("Tungeste sætninger i et tidsrum / Top SQL in a window");
api.MapGet("/instances/{name}/disks", DisksAsync).WithSummary("Diskhastighed pr. drev med vurdering og tidsserier / Disk speed per drive");
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
}
private sealed class Redactor(bool include)
{
public bool Include => include;
public string? Value(string? value, bool sensitive = true) => sensitive && !include && value is not null ? Redacted : value;
}
private static Redactor RedactorFor(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
private static object FindingSummary(StoredFinding f, Redactor redact) => new
{
Id = f.FindingId,
Instance = f.InstanceName,
f.RuleId,
Category = Category(f.RuleId),
f.Subject,
Severity = f.Severity.ToString(),
f.Title,
f.Explanation,
f.Recommendation,
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive) }),
f.FirstSeenAt,
f.LastSeenAt,
f.ResolvedAt,
};
/// <summary>SEC in SQLI-SEC-001, FT in COM-FT-002.</summary>
public static string Category(string ruleId)
{
ArgumentNullException.ThrowIfNull(ruleId);
var parts = ruleId.Split('-');
return parts.Length >= 3 ? parts[1] : ruleId;
}
private static async Task<IResult> SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? instance = null, string? database = null,
string? ruleId = null, Severity? minSeverity = null, string? category = null, string? sort = null, int? limit = null, string? continuation = null)
{
var findingStatus = status?.ToLowerInvariant() switch
{
null or "" or "active" => (FindingStatus?)FindingStatus.Active,
"resolved" => FindingStatus.Resolved,
"all" => FindingStatus.All,
_ => null,
};
var findingSort = sort?.ToLowerInvariant() switch
{
null or "" or "severity" => (FindingSort?)FindingSort.Severity,
"firstseen" => FindingSort.FirstSeen,
"lastseen" => FindingSort.LastSeen,
"resolved" => FindingSort.Resolved,
_ => null,
};
if (findingStatus is null || findingSort is null)
{
return Results.BadRequest("status skal være active, resolved eller all; sort skal være severity, firstSeen, lastSeen eller resolved.");
}
var offset = 0;
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
{
return Results.BadRequest("Ugyldig continuation.");
}
var size = Math.Clamp(limit ?? 100, 1, InsightRepository.MaxFindingPage);
var (findings, more) = await repository.SearchFindingsAsync(new FindingQuery
{
Status = findingStatus.Value,
From = from,
To = to,
Instance = instance,
Database = database,
RuleId = ruleId,
MinSeverity = minSeverity,
Category = category,
Sort = findingSort.Value,
Offset = offset,
Limit = size,
}, ct).ConfigureAwait(false);
if (more)
{
http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
}
var redact = RedactorFor(options);
return Results.Ok(findings.Select(f => FindingSummary(f, redact)));
}
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct)
{
if (await repository.GetFindingAsync(id, ct).ConfigureAwait(false) is not { } f)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
return Results.Ok(new
{
Id = f.FindingId,
Instance = f.InstanceName,
f.RuleId,
Category = Category(f.RuleId),
f.Subject,
Severity = f.Severity.ToString(),
f.Title,
f.Explanation,
f.Recommendation,
Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive), e.Sensitive }),
f.FixScript,
References = f.References.Select(r => new { r.Title, r.Url }),
History = new { f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt, Status = f.ResolvedAt is null ? "active" : "resolved" },
});
}
private static async Task<IResult> RulesAsync(InsightRepository repository, CancellationToken ct)
{
var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget"));
var community = Rules.Lifecycle.RulePack.Embedded().CommunityRules.Select(r => (r.Id, r.Title, Source: "community"));
var seen = (await repository.GetAllRuleStatesAsync(ct).ConfigureAwait(false)).Select(s => s.RuleId);
var known = builtIn.Concat(community).ToList();
var titles = (await repository.GetRuleTitlesAsync(ct).ConfigureAwait(false)).ToDictionary(t => t.RuleId, t => t.Title, StringComparer.OrdinalIgnoreCase);
var local = seen.Distinct(StringComparer.OrdinalIgnoreCase).Where(id => known.All(k => !string.Equals(k.Id, id, StringComparison.OrdinalIgnoreCase)))
.Select(id => (Id: id, Title: titles.GetValueOrDefault(id) ?? id, Source: id.StartsWith("COM-", StringComparison.Ordinal) ? "community" : "lokal"));
return Results.Ok(known.Concat(local).OrderBy(r => r.Id, StringComparer.Ordinal)
.Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source }));
}
private static async Task<IResult> RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
return Results.Ok((await repository.GetRuleStatesAsync(instance.InstanceId, ct).ConfigureAwait(false)).Select(s => new
{
s.RuleId,
s.RuleTitle,
Outcome = s.Outcome switch
{
Rules.Engine.RuleOutcome.Passed => "passed",
Rules.Engine.RuleOutcome.Findings => "findings",
Rules.Engine.RuleOutcome.NotMeasurable => "notMeasurable",
Rules.Engine.RuleOutcome.NotApplicable => "notApplicable",
Rules.Engine.RuleOutcome.ManualCheck => "manualCheck",
_ => "error",
},
s.Reason,
s.EvaluatedAt,
}));
}
private static async Task<IResult> ReportAsync(string type, InsightRepository repository, Display display, IOptions<WebOptions> options, CancellationToken ct,
string? format = null, int? days = null, string? instance = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
{
var pdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
if (format is { Length: > 0 } && !pdf && !string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest("format skal være json eller pdf.");
}
var now = DateTimeOffset.UtcNow;
var redact = RedactorFor(options);
switch (type.ToLowerInvariant())
{
case "driftsrapport":
{
var span = Math.Clamp(days ?? 30, 1, 366);
var data = await ReportData.LoadAsync(repository, options.Value.Organization, now.AddDays(-span), now, display.Zone, TimeProvider.System, ct).ConfigureAwait(false);
return pdf
? Results.File(ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf")
: Results.Ok(ManagementJson(data, redact));
}
case "analyse":
{
if (instance is null || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target)
{
return Results.BadRequest("instance er påkrævet og skal være en kendt instans.");
}
var (start, end) = (from ?? now.AddHours(-1), to ?? now);
if (end <= start || end - start > TimeWindow.MaxLength)
{
return Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.");
}
var analysis = await TimeWindowAnalysis.LoadAsync(repository, target, start, end, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct).ConfigureAwait(false);
return pdf
? Results.File(TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", $"analyse-{now:yyyy-MM-dd}.pdf")
: Results.Ok(AnalysisJson(analysis, redact));
}
case "rettigheder":
{
InstanceOverview? target = null;
if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null)
{
return Results.NotFound();
}
var span = Math.Clamp(days ?? 90, 1, 366);
var audit = await PrivilegeAudit.LoadAsync(repository, target, now.AddDays(-span), now, ct).ConfigureAwait(false);
return pdf
? Results.File(audit.Render(options.Value.Organization, display.Zone), "application/pdf", $"rettigheder-{now:yyyy-MM-dd}.pdf")
: Results.Ok(AuditJson(audit, redact));
}
default:
return Results.NotFound();
}
}
private static object ManagementJson(ReportData data, Redactor redact) => new
{
data.Organization,
data.From,
data.To,
data.GeneratedAt,
TimeZone = data.TimeZone.Id,
data.Version,
data.RulePack,
Summary = ManagementReport.SummarySentences(data),
Period = data.Period,
Controls = ComplianceControl.All.Select(c => new
{
c.Name,
c.Description,
c.Rules,
Instances = data.Instances.Select(i => new { Instance = i.Name, Status = c.StatusOn(i.Name, data.RuleStates).ToString() }),
}),
Instances = data.Instances.Select(i => new
{
i.Name, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.LastSeenAt, i.IsFailing,
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
}),
Findings = data.Active.Select(f => FindingSummary(f, redact)),
ConfigurationChanges = data.ConfigurationChanges.Select(c => ConfigurationChangeJson(c, redact)),
};
private static object ConfigurationChangeJson(StoredConfigurationChange c, Redactor redact)
{
// Service accounts are logins: sensitive, as in the configuration rule's evidence.
var sensitive = c.Area == ConfigurationFingerprint.ServiceAccount;
return new { Instance = c.InstanceName, c.DetectedAt, c.Area, c.Name, OldValue = redact.Value(c.OldValue, sensitive), NewValue = redact.Value(c.NewValue, sensitive) };
}
private static object AnalysisJson(TimeWindowAnalysis a, Redactor redact) => new
{
Instance = a.Instance.Name,
a.From,
a.To,
Resolution = a.Resolution.ToString(),
a.HasData,
Metrics = a.Metrics.Select(m => new
{
m.Metric, m.Label, m.Unit, m.Average, m.Peak, m.NormalAverage, m.IsUnusual,
Assessment = AssessmentJson(a.Assess(m)),
Explanation = MetricAssessment.Explanation(m.Metric),
Points = m.Points,
}),
Waits = a.Waits.Select(w => new { w.WaitType, Category = w.Category.ToString(), w.WaitTimeMs, w.WaitingTasks, w.PerMinute, w.NormalPerMinute, w.Share, w.IsUnusual }),
TopQueries = a.TopQueries.Select(q => TopQueryJson(q, a.ActivityPercent(q), redact)),
Disks = a.Disks.Select(d => DiskJson(d.Window, d.Normal)),
Files = a.Files,
Blocking = a.Blocking.Select(b => new
{
b.SampledAt, b.HeadSessionId, b.BlockedCount, b.MaxWaitMs,
Sessions = b.Sessions.Select(s => new
{
s.SessionId, s.BlockingSessionId, s.Status, s.WaitType, s.WaitTimeMs, s.DatabaseName,
LoginName = redact.Value(s.LoginName), HostName = redact.Value(s.HostName), ProgramName = redact.Value(s.ProgramName), SqlText = redact.Value(s.SqlText),
}),
}),
Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
Findings = a.Findings.Select(f => FindingSummary(f, redact)),
};
private static object AssessmentJson(Assessment a) => new { Level = a.Level.ToString(), Text = a.LevelText, a.Summary, a.NextStep, a.Link };
private static object TopQueryJson(TopQuery q, double? activity, Redactor redact) => new
{
q.QueryKey, q.DatabaseName, Text = redact.Value(q.Text), q.Executions, q.WorkerTimeUs, q.ElapsedTimeUs, q.LogicalReads, q.PhysicalReads, q.LogicalWrites,
ActivityPercent = activity,
};
private static object DiskJson(DiskActivity w, DiskActivity? normal) => new
{
w.Volume, w.DataFiles, w.LogFiles, w.ReadIops, w.WriteIops, w.ReadMbPerSecond, w.WriteMbPerSecond, w.AverageReadMs, w.AverageWriteMs,
Normal = normal is null ? null : new { normal.AverageReadMs, normal.AverageWriteMs, normal.ReadIops, normal.WriteIops },
Assessment = AssessmentJson(MetricAssessment.AssessDisk(w)),
};
private static object AuditJson(PrivilegeAudit audit, Redactor redact) => new
{
audit.From,
audit.To,
Method = PrivilegeAudit.MethodText,
Instances = audit.Instances.Select(e => InstanceAuditJson(e, redact)),
};
private static object InstanceAuditJson(PrivilegeAuditInstance e, Redactor redact) => new
{
Instance = e.Instance.Name,
Measured = e.Snapshot?.CapturedAt,
SysadminCount = e.Snapshot is null ? (int?)null : e.Sysadmins.Count,
MaxSysadminAccounts = e.Snapshot?.MaxSysadminAccounts,
Sysadmins = e.Sysadmins.Select(a => new { Account = redact.Value(a.Account), DisplayName = redact.Value(a.DisplayName), a.Type, Source = a.Source == PrivilegeAnalysis.Direct ? a.Source : redact.Value(a.Source) }),
Principals = (e.Snapshot?.Principals ?? []).Select(p => new
{
Name = redact.Value(p.Name), p.Type, p.Privilege, p.IsDisabled, p.ExpansionError,
Members = p.Members?.Select(m => new { Account = redact.Value(m.Account), DisplayName = redact.Value(m.DisplayName), m.Enabled }),
}),
Sa = e.Snapshot is null ? null : PrivilegeAudit.SaText(e.Sa),
LoginsWithoutPolicy = e.LoginsWithoutPolicy.Select(l => redact.Value(l.Name)),
DefaultTrace = e.Snapshot is null ? null : PrivilegeAudit.TraceText(e.Snapshot),
Changes = e.Changes.Select(c => new
{
c.DetectedAt, c.Kind, c.Privilege, Principal = redact.Value(c.Principal), c.PrincipalType, ViaGroup = redact.Value(c.ViaGroup),
ChangedBy = redact.Value(c.ChangedBy), c.ChangedAt,
}),
};
private static async Task<(InstanceOverview? Instance, DateTimeOffset From, DateTimeOffset To, IResult? Error)> WindowAsync(
string name, InsightRepository repository, DateTimeOffset? from, DateTimeOffset? to, TimeSpan defaultLength, CancellationToken ct)
{
var instance = await repository.GetInstanceAsync(name, ct).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var (start, end) = (from ?? (to ?? now) - defaultLength, to ?? now);
IResult? error = instance is null ? Results.NotFound()
: end <= start || end - start > TimeWindow.MaxLength ? Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.")
: null;
return (instance, start, end, error);
}
private static async Task<IResult> AnalysisAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
{
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(1), ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var analysis = await TimeWindowAnalysis.LoadAsync(repository, instance!, start, end, DateTimeOffset.UtcNow, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct)
.ConfigureAwait(false);
return Results.Ok(AnalysisJson(analysis, RedactorFor(options)));
}
private static async Task<IResult> TopSqlAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null, int? limit = null)
{
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var seconds = (end - start).TotalSeconds;
var queries = await repository.GetTopQueriesAsync(instance!.InstanceId, start, end, AnalysisOrder.Parse(sort), Math.Clamp(limit ?? 25, 1, 200), ct).ConfigureAwait(false);
var redact = RedactorFor(options);
return Results.Ok(new
{
Instance = instance.Name,
From = start,
To = end,
Queries = queries.Select(q => TopQueryJson(q, 100.0 * q.WorkerTimeUs / (seconds * 1_000_000), redact)),
});
}
private static async Task<IResult> DisksAsync(string name, InsightRepository repository, CancellationToken ct, DateTimeOffset? from = null, DateTimeOffset? to = null)
{
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
var disks = hourly
? await repository.GetDiskActivityHourlyAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false)
: await repository.GetDiskActivityAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false);
var series = await repository.GetDiskSeriesAsync(instance.InstanceId, start, ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
From = start,
To = end,
Resolution = hourly ? "Hourly" : "Raw",
Explanation = new { Read = MetricAssessment.Explanation("disk.read"), LogWrite = MetricAssessment.Explanation("disk.write.log"), Write = MetricAssessment.Explanation("disk.write") },
Drives = disks.Select(d => DiskJson(d, null)),
Series = series,
});
}
private static async Task<IResult> ConfigurationAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var id = instance.InstanceId;
var setup = await repository.GetSnapshotAsync<InstanceConfigurationSnapshot>(id, "instance-config", ct).ConfigureAwait(false);
var configuration = await repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration", ct).ConfigureAwait(false);
var traceFlags = await repository.GetSnapshotAsync<TraceFlagSnapshot>(id, "trace-flags", ct).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var changes = await repository.GetConfigurationChangesAsync(id, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, 500, ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
Setup = setup is null ? null : new
{
setup.CapturedAt, setup.ProductVersion, setup.ProductLevel, setup.Edition, setup.Collation, setup.ServicesAvailable,
Services = setup.Services.Select(s => new { s.Name, Account = redact.Value(s.Account), s.StartupType, s.Status, s.InstantFileInitialization }),
setup.InstantFileInitialization, setup.MemoryModel, setup.TempdbFiles, setup.DefaultDataPath, setup.DefaultLogPath,
},
Options = configuration?.Options.Select(o => new { o.Name, o.Value, o.ValueInUse, Default = ConfigurationFingerprint.DefaultValue(o.Name) }),
TraceFlags = traceFlags?.GlobalTraceFlags,
Changes = changes.Select(c => ConfigurationChangeJson(c, redact)),
});
}
private static async Task<IResult> InstancePrivilegesAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var now = DateTimeOffset.UtcNow;
var audit = await PrivilegeAudit.LoadAsync(repository, instance, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
return Results.Ok(InstanceAuditJson(audit.Instances[0], RedactorFor(options)));
}
private static async Task<IResult> AllPrivilegesAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
var now = DateTimeOffset.UtcNow;
var audit = await PrivilegeAudit.LoadAsync(repository, null, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
return Results.Ok(AuditJson(audit, RedactorFor(options)));
}
private static async Task<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
DateTimeOffset? from = null, DateTimeOffset? to = null)
{
var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
if (error is not null)
{
return error;
}
var type = waitType.Trim().ToUpperInvariant();
var explanation = WaitKnowledge.Lookup(type);
var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
var series = await repository.GetWaitTypeSeriesAsync(instance!.InstanceId, type, start, end, hourly, ct).ConfigureAwait(false);
var statements = await repository.GetWaitTypeStatementsAsync(instance.InstanceId, type, start, end, 25, ct).ConfigureAwait(false);
var redact = RedactorFor(options);
return Results.Ok(new
{
Instance = instance.Name,
WaitType = type,
From = start,
To = end,
Explanation = new { explanation.Title, explanation.Meaning, explanation.Normal, explanation.Causes, explanation.Actions, explanation.DynamicsAx, explanation.Known },
WaitMsPerMinute = series,
Statements = statements.Select(s => new
{
s.QueryKey, s.DatabaseName, Text = redact.Value(s.Text), s.Samples, s.Share, s.EstimatedWaitSeconds, s.AverageWaitMs, s.MaxWaitMs, s.BlockedSamples,
}),
});
}
}

View file

@ -16,7 +16,7 @@ public static class IntegrationEndpoints
public static void Map(IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
var api = app.MapGroup("/api/v1").DisableAntiforgery();
api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) =>
(await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new
{
@ -30,12 +30,33 @@ public static class IntegrationEndpoints
i.LastError,
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
i.NotMeasurable,
i.ManualChecks,
}))
.WithSummary("Instanser med status og antal fund / Instances");
}));
api.MapGet("/findings", async (string? instance, Severity? minSeverity, InsightRepository repository, Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
{
var findings = await repository.GetActiveFindingsAsync(cancellationToken: ct).ConfigureAwait(false);
var includeSensitive = options.Value.ApiIncludesSensitiveEvidence;
return findings
.Where(f => instance is null || string.Equals(f.InstanceName, instance, StringComparison.OrdinalIgnoreCase))
.Where(f => minSeverity is null || f.Severity >= minSeverity)
.Select(f => new
{
Id = f.FindingId,
Instance = f.InstanceName,
f.RuleId,
f.Subject,
Severity = f.Severity.ToString(),
f.Title,
f.Explanation,
f.Recommendation,
Evidence = f.Evidence.Select(e => new { e.Name, Value = e.Sensitive && !includeSensitive ? "[udeladt]" : e.Value }),
f.FirstSeenAt,
f.LastSeenAt,
});
});
app.MapGet("/metrics", async (InsightRepository repository, CancellationToken ct) =>
Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8")).RequireRead();
Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8"));
}
private static bool IsUp(InstanceOverview i) => !i.IsFailing && i.LastSeenAt is { } seen && DateTime.UtcNow - seen < UpWindow;

View file

@ -1,137 +0,0 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using SQLInsight.Repository;
namespace SQLInsight.Web.Api;
/// <summary>Body of POST /api/v1/support-cases.</summary>
public sealed record SupportCaseCreateBody(string? Title, string? Description, string? ContactName, string? ContactEmail, string? ContactPhone, long? FindingId);
/// <summary>Body of POST /api/v1/support-cases/{id}/close.</summary>
public sealed record SupportCaseCloseBody(string? Comment);
/// <summary>
/// Support cases through the API, e.g. from an ITSM system: create, list, read and close. The same logic as the
/// /support page; the agent sends them to SQLInsight Hub. Writing needs the scope support:write for tokens.
/// </summary>
public static class SupportApiEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
var api = app.MapGroup("/api/v1/support-cases").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
api.MapPost("", CreateAsync)
.WithSummary("Opret en supportsag / Create a support case")
.WithDescription("202 Accepted with the local id and status Pending; the case number (SUP-…) follows when the agent has heard from SQLInsight Hub. "
+ "Idempotency-Key header: the same key returns the same case. Needs scope support:write for tokens.");
api.MapGet("", ListAsync).WithSummary("Kundens supportsager / Support cases (status, from, to, limit, continuation)");
api.MapGet("/{id:long}", GetAsync).WithSummary("Én supportsag med status og sagsnummer / One support case");
api.MapPost("/{id:long}/close", CloseAsync)
.WithSummary("Luk en supportsag / Close a support case")
.WithDescription("Status CloseRequested until the hub has confirmed it, then Closed. Closing a closed case returns 200. Needs scope support:write for tokens.");
}
private static IResult Forbidden(string detail) =>
Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", detail: detail);
private static async Task<IResult> CreateAsync(SupportCaseCreateBody body, HttpContext http, InsightRepository repository, CancellationToken ct)
{
if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite))
{
return Forbidden("API-tokenet mangler scope \"support:write\".");
}
var key = http.Request.Headers["Idempotency-Key"].ToString();
var clientRequestId = key.Length == 0 ? Guid.NewGuid() : RequestId(key);
var result = await Support.FileAsync(repository, clientRequestId, body.Title, body.Description, body.ContactName, body.ContactEmail, body.ContactPhone,
body.FindingId, http.User.Identity?.Name, ct).ConfigureAwait(false);
if (result.NotAllowed)
{
return Forbidden(Support.NotAllowedText);
}
if (result.Error is { } error)
{
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig sag", detail: error);
}
var row = (await repository.GetSupportCaseAsync(result.CaseId!.Value, ct).ConfigureAwait(false))!;
return Results.Accepted($"/api/v1/support-cases/{row.CaseId}", Json(row));
}
/// <summary>An Idempotency-Key as the request id: a GUID as it is, anything else hashed to one.</summary>
public static Guid RequestId(string key)
{
ArgumentNullException.ThrowIfNull(key);
return Guid.TryParse(key, out var guid) ? guid : new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(key)).AsSpan(0, 16));
}
private static async Task<IResult> ListAsync(InsightRepository repository, HttpContext http, CancellationToken ct,
string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, int? limit = null, string? continuation = null)
{
var offset = 0;
if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
{
return Results.BadRequest("Ugyldig continuation.");
}
var size = Math.Clamp(limit ?? 100, 1, 1000);
var (cases, more) = await repository.SearchSupportCasesAsync(status, from, to, offset, size, ct).ConfigureAwait(false);
if (more)
{
http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
}
return Results.Ok(cases.Select(Json));
}
private static async Task<IResult> GetAsync(long id, InsightRepository repository, CancellationToken ct) =>
await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound();
private static async Task<IResult> CloseAsync(long id, HttpContext http, InsightRepository repository, CancellationToken ct, SupportCaseCloseBody? body = null)
{
if (!ApiTokenHandler.HasScope(http.User, ApiToken.SupportWrite))
{
return Forbidden("API-tokenet mangler scope \"support:write\".");
}
if (body?.Comment?.Length > Contracts.SupportCaseCloseRequest.MaxComment)
{
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig lukning", detail: $"Kommentaren må højst være {Contracts.SupportCaseCloseRequest.MaxComment} tegn.");
}
var outcome = await repository.RequestSupportCloseAsync(id, http.User.Identity?.Name, body?.Comment, DateTimeOffset.UtcNow, ct).ConfigureAwait(false);
if (outcome == SupportCloseOutcome.NotFound)
{
return Results.NotFound();
}
if (outcome == SupportCloseOutcome.Failed)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Sagen kan ikke lukkes", detail: "SQLInsight Hub afviste sagen, så den findes ikke hos SEOSoft.");
}
var row = (await repository.GetSupportCaseAsync(id, ct).ConfigureAwait(false))!;
return outcome == SupportCloseOutcome.AlreadyClosed ? Results.Ok(Json(row)) : Results.Accepted($"/api/v1/support-cases/{id}", Json(row));
}
private static object Json(SupportCaseRow c) => new
{
Id = c.CaseId,
c.Reference,
c.Title,
c.Status,
c.Source,
c.FindingId,
c.InstanceName,
c.RuleId,
c.RequestedBy,
c.CreatedAt,
c.SentAt,
c.CloseRequestedAt,
c.ClosedAt,
c.ClosedBy,
Error = c.Status == SupportCaseStatus.Failed ? c.LastError : null,
};
}

View file

@ -9,10 +9,18 @@
<span>SQLInsight <strong>Pro</strong></span>
</a>
@* On narrow screens the menu folds away behind "Menu" (a checkbox: no script). *@
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
<label for="nav-toggle" class="nav-toggle-label"><Icon Name="list" /> Menu</label>
<MainNav />
<nav class="nav">
<span class="nav-label">Overvågning</span>
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
<span class="nav-label">Community</span>
<a href="@Options.Value.ForumUrl" rel="noopener"><Icon Name="users" /> Forum</a>
<a href="@(new Uri(Options.Value.CommunityUrl, "dokumentation"))" rel="noopener"><Icon Name="book" /> Dokumentation</a>
</nav>
<div class="support-card">
<Icon Name="support" />

View file

@ -1,92 +0,0 @@
@inject NavigationManager Navigation
@inject Microsoft.Extensions.Options.IOptions<WebOptions> Options
@inject InsightRepository Repository
@* The menu in foldable groups (details/summary: no script needed). The group with the current page is open. *@
<nav class="nav" aria-label="Hovedmenu">
@foreach (var group in Groups)
{
if (group.Key == "ax" && !_dynamicsAx)
{
continue;
}
<details class="nav-group" open="@(IsOpen(group))" data-nav-group="@group.Key">
<summary><Icon Name="@group.Icon" /> <span>@group.Title</span> <Icon Name="chevron" /></summary>
@foreach (var item in group.Items)
{
<NavLink href="@item.Href" Match="@(item.Href == "/" ? NavLinkMatch.All : NavLinkMatch.Prefix)">@item.Title</NavLink>
}
</details>
}
<NavLink href="/support" class="nav-single"><Icon Name="support" /> Support</NavLink>
<NavLink href="/system" class="nav-single"><Icon Name="gauge" /> System</NavLink>
<details class="nav-group" data-nav-group="community">
<summary><Icon Name="users" /> <span>Community</span> <Icon Name="chevron" /></summary>
<a href="@Options.Value.ForumUrl" rel="noopener">Forum</a>
<a href="@(new Uri(Options.Value.CommunityUrl, "dokumentation"))" rel="noopener">Dokumentation</a>
</details>
</nav>
@code {
public sealed record NavItem(string Title, string Href, params string[] Also);
public sealed record NavGroup(string Key, string Title, string Icon, IReadOnlyList<NavItem> Items);
/// <summary>The menu. Later pages go into the group they belong to.</summary>
public static readonly IReadOnlyList<NavGroup> Groups =
[
new("overvaagning", "Overvågning", "servers",
[
new("Overblik", "/"),
new("Alle fund", "/fund", "/fund/"),
]),
new("analyse", "Analyse", "chart",
[
new("Analyse af tidsrum", "/vaelg/analyse", "/analyse", "/sql"),
new("Top-SQL", "/vaelg/topsql"),
new("Ventetyper", "/vaelg/ventetyper", "/ventetyper/"),
new("Diskhastighed", "/vaelg/disk"),
]),
new("sikkerhed", "Konfiguration og sikkerhed", "shield",
[
new("Konfiguration", "/vaelg/konfiguration", "/konfiguration"),
new("Rettigheder", "/rettigheder", "/rettigheder"),
]),
new("rapporter", "Rapporter", "book",
[
new("Rapporter", "/rapporter"),
new("Revisionsrapport", "/rapporter#rettighedsrevision"),
]),
new("ax", "Dynamics AX", "cube", []),
];
private string _path = "/";
private bool _dynamicsAx;
protected override async Task OnInitializedAsync()
{
_path = "/" + Navigation.ToBaseRelativePath(Navigation.Uri).Split('?', '#')[0];
_dynamicsAx = Groups.Single(g => g.Key == "ax").Items.Count > 0 && await Repository.HasDynamicsAxDatabasesAsync();
}
/// <summary>The group holding the current page; Overvågning on the front page and the instance pages.</summary>
private bool IsOpen(NavGroup group) =>
group.Items.Any(i => Matches(i)) || group.Key == "overvaagning" && !Groups.Any(g => g.Key != "overvaagning" && g.Items.Any(Matches));
private bool Matches(NavItem item)
{
var href = item.Href.Split('?', '#')[0];
if (href == "/" ? _path == "/" : _path.StartsWith(href, StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Pages under /instanser/{name}/… belong to the group of the menu entry for that kind of page.
return item.Also.Any(a => a.StartsWith('/') && _path.StartsWith("/instanser/", StringComparison.OrdinalIgnoreCase)
? _path.Contains(a, StringComparison.OrdinalIgnoreCase)
: _path.StartsWith(a, StringComparison.OrdinalIgnoreCase));
}
}

View file

@ -1,69 +0,0 @@
@page "/rettigheder"
@inject InsightRepository Repository
<PageTitle>Rettigheder · SQLInsight Pro</PageTitle>
<h1>Rettigheder</h1>
<p class="lead">Sysadmin og de øvrige forhøjede serverrettigheder på alle instanser, og hvem der har fået eller mistet dem.</p>
<p class="report-links">
<a class="button" href="/rapporter/rettigheder.pdf?dage=@Dage">Revisionsrapport for alle instanser (PDF)</a>
</p>
@if (_audit is not null)
{
<section class="card">
<div class="card-head"><h2>Instanser</h2></div>
<table>
<thead><tr><th>Instans</th><th class="num">Sysadmin</th><th class="num">Grænse</th><th>sa</th><th class="num">SQL-logins uden politik</th><th>Default trace</th><th class="num">Ændringer</th></tr></thead>
<tbody>
@foreach (var e in _audit.Instances)
{
<tr>
<td><a href="/instanser/@Uri.EscapeDataString(e.Instance.Name)/rettigheder"><strong>@e.Instance.Name</strong></a></td>
@if (e.Snapshot is null)
{
<td colspan="5" class="muted">endnu ikke målt</td>
}
else
{
<td class="num @(e.Sysadmins.Count > e.Snapshot.MaxSysadminAccounts ? "bad" : "")">@e.Sysadmins.Count@(PrivilegeAnalysis.UnfoldedGroupsMissing(e.Snapshot, PrivilegeAnalysis.Sysadmin).Count > 0 ? "+" : "")</td>
<td class="num">@e.Snapshot.MaxSysadminAccounts</td>
<td class="@(e.Sa is { IsDisabled: false } ? "bad" : "")">@SaShort(e.Sa)</td>
<td class="num @(e.LoginsWithoutPolicy.Count > 0 ? "bad" : "")">@e.LoginsWithoutPolicy.Count</td>
<td class="@(e.Snapshot.DefaultTraceEnabled == false ? "bad" : "")">@(e.Snapshot.DefaultTraceEnabled switch { true => "til", false => "fra", null => "ukendt" })</td>
}
<td class="num">@e.Changes.Count</td>
</tr>
}
</tbody>
</table>
<p class="muted">"+" efter antallet: en Windows-gruppe med sysadmin kunne ikke foldes ud, så der kan være flere.</p>
</section>
<section class="card">
<div class="card-head"><h2>Ændringslog</h2><span class="muted">seneste @Dage dage</span></div>
<PeriodLinks Days="Dage!.Value" />
<PrivilegeChanges Changes="_changes" ShowInstance="true" />
</section>
}
@code {
[SupplyParameterFromQuery] public int? Dage { get; set; }
private PrivilegeAudit? _audit;
private IReadOnlyList<StoredPrivilegeChange> _changes = [];
protected override async Task OnParametersSetAsync()
{
Dage = Math.Clamp(Dage ?? 90, 1, 366);
var now = DateTimeOffset.UtcNow;
_audit = await PrivilegeAudit.LoadAsync(Repository, null, now.AddDays(-Dage.Value), now);
_changes = _audit.Instances.SelectMany(i => i.Changes).OrderByDescending(c => c.DetectedAt).ToList();
}
private static string SaShort(SqlLoginInfo? sa) => sa switch
{
null => "findes ikke",
{ IsDisabled: true } => "deaktiveret",
_ => "aktiv",
};
}

View file

@ -58,7 +58,6 @@
<div class="panel @(m.IsUnusual ? "unusual" : "")">
<Sparkline Points="m.Points" Label="@m.Label" Unit="@m.Unit" Max="m.Max" Decimals="m.Decimals" />
<p class="muted small">Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")</p>
<AssessmentNote Assessment="_analysis.Assess(m)" Explanation="@MetricAssessment.Explanation(m.Metric)" InstanceName="@_instance.Name" />
</div>
}
</div>
@ -77,7 +76,7 @@
@foreach (var w in _analysis.Waits.Take(15))
{
<tr class="@(w.IsUnusual ? "unusual" : "")">
<td class="mono"><a href="@WaitUrl(w.WaitType)">@w.WaitType</a></td>
<td class="mono">@w.WaitType</td>
<td>@w.Category</td>
<td class="num">@Display.Number(w.WaitTimeMs / 1000.0, 1) s</td>
<td class="num">@Display.Number(w.PerMinute / 1000.0, 2) s</td>
@ -91,7 +90,7 @@
</section>
<section class="card">
<div class="card-head" id="topsql"><h2>Tungeste forespørgsler</h2><span class="muted">summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div>
<div class="card-head"><h2>Tungeste forespørgsler</h2><span class="muted">summeret over tidsrummet; teksten bliver her og sendes aldrig til SQLInsight Hub</span></div>
@if (_analysis.TopQueries.Count == 0)
{
<p class="muted">Ingen forespørgselsstatistik i tidsrummet. Den indsamles hvert 5. minut fra version 1.0.3 og gemmes i @_analysis.RawDays dage.</p>
@ -99,17 +98,16 @@
else
{
<table class="queries">
<thead><tr><th>Forespørgsel</th><th class="num">CPU</th><th class="num">Varighed</th><th class="num">Læsninger</th><th class="num">Kørsler</th><th class="num" title="CPU-tid som andel af tidsrummet: 100 % = én CPU-kerne hele tiden">Aktivitet</th></tr></thead>
<thead><tr><th>Forespørgsel</th><th class="num">CPU</th><th class="num">Varighed</th><th class="num">Læsninger</th><th class="num">Kørsler</th></tr></thead>
<tbody>
@foreach (var q in _analysis.TopQueries)
{
<tr>
<td><span class="muted small">@q.DatabaseName</span><pre class="sql">@(q.Text ?? "(teksten er ikke længere i plan cache)")</pre><a class="small" href="@($"{InstanceUrl}/sql?noegle={Uri.EscapeDataString(q.QueryKey)}")">Hele teksten</a></td>
<td><span class="muted small">@q.DatabaseName</span><pre class="sql">@(q.Text ?? "(teksten er ikke længere i plan cache)")</pre></td>
<td class="num nowrap">@Display.Number(q.WorkerTimeUs / 1_000_000.0, 1) s</td>
<td class="num nowrap">@Display.Number(q.ElapsedTimeUs / 1_000_000.0, 1) s</td>
<td class="num">@Display.Number(q.LogicalReads)</td>
<td class="num">@Display.Number(q.Executions)</td>
<td class="num @(_analysis.ActivityPercent(q) > 50 ? "bad" : "")">@Display.Number(_analysis.ActivityPercent(q)) %</td>
</tr>
}
</tbody>
@ -117,11 +115,6 @@
}
</section>
<section class="card">
<div class="card-head"><h2>Diskhastighed pr. drev</h2></div>
<DiskTable Disks="_analysis.Disks" ShowNormal="true" InstanceName="@_instance.Name" Empty="Ingen målinger pr. drev i tidsrummet (indsamles fra version 1.0.5)." />
</section>
<section class="card">
<div class="card-head"><h2>I/O-latens pr. fil</h2></div>
@if (_analysis.Files.Count == 0)
@ -150,14 +143,9 @@
}
</section>
<section class="card">
<div class="card-head"><h2>Langvarige operationer</h2><span class="muted">backup, restore, DBCC m.m.</span></div>
<LongOperationTable Operations="_analysis.LongOperations" Empty="Ingen operationer med fremskridt i tidsrummet." />
</section>
<div class="grid2">
<section class="card">
<div class="card-head" id="blocking"><h2>Blocking</h2></div>
<div class="card-head"><h2>Blocking</h2></div>
@if (_analysis.Blocking.Count == 0)
{
<p class="muted">Ingen blocking over 5 sekunder i tidsrummet.</p>
@ -260,7 +248,5 @@
_analysis = await TimeWindowAnalysis.LoadAsync(Repository, _instance, _from, _to, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(Sortering));
}
private string WaitUrl(string waitType) => $"{InstanceUrl}/ventetyper/{Uri.EscapeDataString(waitType)}?fra={Uri.EscapeDataString(TimeWindow.ToInput(_from, Display.Zone))}&til={Uri.EscapeDataString(TimeWindow.ToInput(_to, Display.Zone))}";
private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–";
}

View file

@ -1,69 +0,0 @@
@page "/vaelg/{Side}"
@inject InsightRepository Repository
@inject NavigationManager Navigation
@* Menu entries for pages that exist per instance lead here: pick the instance (or go straight there with only one). *@
@if (_target is null)
{
<PageTitle>Ukendt side · SQLInsight Pro</PageTitle>
<h1>Ukendt side</h1>
<p>Siden <strong>@Side</strong> findes ikke.</p>
return;
}
<PageTitle>@_target.Value.Title · SQLInsight Pro</PageTitle>
<h1>@_target.Value.Title</h1>
<p class="lead">Vælg instans.</p>
@if (_instances.Count == 0)
{
<p class="muted">Der er endnu ingen instanser. Tjek, at SQLInsight Agent kører og har instanser under <code>SQLInsight:Instances</code>.</p>
}
else
{
<section class="card">
<table>
<thead><tr><th>Instans</th><th>Server</th></tr></thead>
<tbody>
@foreach (var i in _instances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase))
{
<tr><td><a href="@Url(i.Name)"><strong>@i.Name</strong></a></td><td class="muted">@i.ServerName</td></tr>
}
</tbody>
</table>
</section>
}
@code {
/// <summary>Menu entry → title and the page under /instanser/{name}.</summary>
public static readonly IReadOnlyDictionary<string, (string Title, string Path)> Targets = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase)
{
["analyse"] = ("Analyse af tidsrum", "/analyse"),
["topsql"] = ("Top-SQL", "/analyse?sortering=cpu#topsql"),
["ventetyper"] = ("Ventetyper", "#ventetider"),
["disk"] = ("Diskhastighed", "#disk"),
["konfiguration"] = ("Konfiguration", "/konfiguration"),
};
[Parameter] public string Side { get; set; } = string.Empty;
private (string Title, string Path)? _target;
private IReadOnlyList<InstanceOverview> _instances = [];
protected override async Task OnParametersSetAsync()
{
_target = Targets.TryGetValue(Side, out var target) ? target : null;
if (_target is null)
{
return;
}
_instances = await Repository.GetInstancesAsync();
if (_instances.Count == 1)
{
Navigation.NavigateTo(Url(_instances[0].Name));
}
}
private string Url(string instance) => "/instanser/" + Uri.EscapeDataString(instance) + _target!.Value.Path;
}

Some files were not shown because too many files have changed in this diff Show more